diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..51cbb056 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,7 @@ +# GitHub Copilot instructions + +See [AGENTS.md](../AGENTS.md) for how to work in this repository, including the runtime model that +underlies environments, teardown, threading, and object lifetime, plus the build/format/test steps. + +Key reminder: run `dotnet format --severity info --verbosity detailed` after code changes (PR builds +fail on formatting violations), and run `dotnet pack` before `dotnet test`. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..37240b15 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,59 @@ +# Repository guide for AI agents + +This file orients automated coding agents (and new contributors) working in this repository. It is +intentionally short; it points at the authoritative docs rather than duplicating them. + +`node-api-dotnet` provides high-performance, in-process interop between .NET and JavaScript, built on +[Node-API](https://nodejs.org/api/n-api.html). It ships a runtime library, a native + managed host, +a C# source generator, and a TypeScript type-definitions generator. + +## Read this first: the runtime model + +Most recurring misunderstandings in this codebase come from the JavaScript environment / .NET +context lifetime model. **Read [docs/concepts/runtime-model.md](docs/concepts/runtime-model.md) +before reasoning about environments, teardown, threading, or object lifetime.** The facts that are +most often gotten wrong: + +- **Node.js creates one `napi_env` per loaded native module.** A Native AOT module is `1 env : 1` + `JSRuntimeContext`. A managed module runs a native host and a managed host that **share one env** + (two instance-data slots) — that is the *only* case where two contexts share an env. Two + independently compiled AOT addons are two separate modules and therefore get **two different + envs**; they never share one, so their per-environment state cannot collide. +- **`node::Environment` is not `napi_env`.** There is one `node::Environment` per V8 isolate / worker + thread, and **zero or more `napi_env` per `node::Environment`** (one per native module). An + environment cleanup hook is associated with the `node::Environment`; the **instance-data finalizer + is per `napi_env`.** Per-context teardown keys off the instance-data finalizer, not the cleanup + hook. +- **Finalizers run during environment teardown, where calling into JavaScript is forbidden.** Resolve + the context with `JSRuntimeContext.FromEnv(env)`, never by dereferencing a finalize hint that may be + freed, and assume no ordering between wrapped-object finalizers and the instance-data finalizer. +- **`napi_value` / `JSValue` are valid only within their `JSValueScope` and only on the JS thread.** + To keep a value beyond its scope, hold a `JSReference` (`napi_ref`). There are three scope types — + runtime-context, handle, and escapable — and a module boundary starts a fresh module holder so each + loaded module resolves its own module instance. + +## Build, format, and test + +Full details are in [README-DEV.md](README-DEV.md). The essentials: + +```bash +dotnet build +dotnet format --severity info --verbosity detailed # PR builds FAIL if formatting is non-compliant +dotnet pack # required before tests (the generator is consumed as a local package) +dotnet test +``` + +- **Run `dotnet format` after code changes and before tests** — formatting is a CI gate. +- **`dotnet pack` is required before `dotnet test`**, and again after any change to the source + generator, because tests consume the generator through the locally built NuGet package. Use + `-c Release` for release-configuration testing. +- Most test cases run twice: once in hosted CLR mode and once in Native AOT mode. Test cases are + derived from the `.js` files under `test/TestCases`. + +## Conventions + +- Follow the existing code style enforced by `.editorconfig` (American English in code, comments, and + docs). +- See [docs/contributing.md](docs/contributing.md) for contribution guidelines, and + [docs/NodeApi-Layers.md](docs/NodeApi-Layers.md) for how the assemblies and namespaces are layered. +- Keep code comments minimal: add one only to explain a non-obvious "why" that the code cannot show. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..aaca4204 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,7 @@ +# Guidance for Claude + +See [AGENTS.md](AGENTS.md) for how to work in this repository, including the runtime model that +underlies environments, teardown, threading, and object lifetime, plus the build/format/test steps. + +Key reminder: run `dotnet format --severity info --verbosity detailed` after code changes (PR builds +fail on formatting violations), and run `dotnet pack` before `dotnet test`. diff --git a/Directory.Packages.props b/Directory.Packages.props index 973b56fb..7c5e7614 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -11,7 +11,7 @@ - + diff --git a/bench/Benchmarks.cs b/bench/Benchmarks.cs index c3461ab5..937ff8f6 100644 --- a/bench/Benchmarks.cs +++ b/bench/Benchmarks.cs @@ -144,7 +144,7 @@ protected void Setup() _reference = new JSReference(_jsFunction); } - private static JSValueScope NewJSScope() => new(JSValueScopeType.Callback); + private static JSValueScope NewJSScope() => JSValueScope.CreateRuntimeScope(); // Benchmarks in the base class run in both CLR and AOT environments. diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 3f87ab30..fdf9a0c5 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -17,6 +17,13 @@ export default defineConfig({ sidebar: [ { text: 'Overview', link: '/overview' }, + { + text: 'Concepts', + items: [ + { text: 'Runtime model', link: '/concepts/runtime-model' }, + { text: 'Project layers', link: '/NodeApi-Layers' }, + ] + }, { text: 'Get Started', items: [ diff --git a/docs/concepts/runtime-model.md b/docs/concepts/runtime-model.md new file mode 100644 index 00000000..569e815e --- /dev/null +++ b/docs/concepts/runtime-model.md @@ -0,0 +1,141 @@ +# Runtime model: environments, lifetimes, and threads + +This page describes the foundational runtime model that the rest of the library is built on: +how JavaScript environments map to .NET runtime contexts, how those contexts are torn down, and +the rules for safely holding JavaScript values. The per-feature pages +([JS value scopes](../features/js-value-scopes), [JS references](../features/js-references), +[JS threading & async](../features/js-threading-async), +[Node worker threads](../features/node-workers)) assume the model described here. + +If you are extending this library or reviewing a change to it, read this first — several parts of +the design only make sense once the environment/module relationship is clear. + +## Environments and module instances + +**Node.js creates a unique `napi_env` for each native module it loads.** When a module is +registered, `napi_module_register_by_symbol` (in Node's `src/node_api.cc`) calls `NodeApiEnv::New`, +which mints a fresh `napi_env` for that specific module. So the mapping is per-module, not +per-process and not per-isolate. + +That gives three deployment shapes: + +| Shape | `napi_env` : `JSRuntimeContext` | Notes | +| --- | --- | --- | +| **Native AOT module** | 1 : 1 | The `.node` file *is* the module, so Node makes one env and the module owns one context. | +| **Managed module** (`.node` native host + managed host) | 1 : 2 | The native host and the managed host run in **separate .NET runtimes** but share the **same** env. Each registers its own context. | +| **Embedding** (a .NET app hosting `libnode`) | 1 : 1 per env | The .NET app creates and owns each environment's context. | + +The managed-module case is the only one where two contexts share a single `napi_env`. The native +host (`NativeHost`, AOT-compiled into the `.node`) initializes first and hands the same env to the +managed host (`ManagedHost`, loaded into the default .NET runtime); both create a `JSRuntimeContext` +for that one env. This is deliberate and bounded — there are never more than these two. + +**A consequence worth stating explicitly:** two independently compiled AOT addons are two separate +native modules, so Node gives them **two different `napi_env` instances**. They never share one +environment, and their per-environment state never collides. The same is true for an AOT addon +loaded alongside the managed host: different modules, different envs. + +## `node::Environment` vs `napi_env` vs isolate/worker + +These three are easy to conflate, but they nest at different granularities: + +- **`node::Environment`** — one per V8 isolate, i.e. one per Node.js **worker thread** (the main + thread is a worker too). It owns the event loop and the environment-cleanup hook list. +- **`napi_env`** — **zero or more per `node::Environment`**, one for each native module loaded into + that worker. Node-API objects, references, and instance data all belong to a specific `napi_env`. +- **isolate/worker thread** — the JS execution thread. All JS values and value scopes have affinity + to it. + +Two teardown callbacks live at these different levels, and the difference matters: + +- An **environment cleanup hook** (`napi_add_env_cleanup_hook`, backed by + `node::AddEnvironmentCleanupHook`) is associated with the **`node::Environment`**. It fires once + when the whole worker shuts down. +- The **instance-data finalizer** (registered with `napi_set_instance_data`) is associated with a + **single `napi_env`**. It fires when that module's environment is torn down. + +Because a `JSRuntimeContext` is scoped to one `napi_env`, this library keys per-context teardown off +the **instance-data finalizer**, not the environment cleanup hook. Using the cleanup hook would be +both too coarse (one worker may host several envs) and wrongly timed for per-module lifetime. + +## Instance-data ownership (`JSRuntimeContext`) + +Each context roots itself with a `GCHandle` stored in its env's instance-data block. Because the +managed-module case puts two contexts (in two separate .NET runtimes/GC heaps) on one env, the block +has **two slots**: + +- **slot 0** — the module context: managed host, AOT module, or embedding. +- **slot 1** — the native host context. + +There are exactly two slots because the native-host + managed-host pair is the only case where two +contexts share an env. A runtime **reads and writes only its own slot**, so it never dereferences a +`GCHandle` that belongs to the other runtime's GC heap (which would be undefined behavior). + +`JSRuntimeContext.FromEnv(napi_env)` resolves the calling runtime's context from its slot. This is +how callback dispatch and finalizers recover the context when no scope is yet current on the thread. + +At environment teardown the instance-data finalizer disposes the owning context, which **clears its +slot and frees the rooting `GCHandle`**. Disposing a host context cascades synchronously to the +other slot's context, so once every context on the env is gone the finalizer **frees the block**. +Freeing it there is no less safe than keeping it: a finalizer that called `FromEnv` after the +instance-data finalizer would already be reading Node's own freed finalizer record (Node does not +null its instance-data pointer), so retaining the block never protected that case. The block is not +nulled out via `napi_set_instance_data` — that would delete the finalizer record Node is running and +then double-free it. + +## JavaScript value scopes + +Every `JSValue` belongs to a [`JSValueScope`](../features/js-value-scopes). There are three scope +types, each created by a static factory: + +- **Runtime-context scope** — `JSValueScope.CreateRuntimeScope(env, context)`. References a + `JSRuntimeContext` and marks a call/context boundary. It opens no napi handle scope. This is the + scope opened at a module entry point or a callback into .NET. +- **Handle scope** — `JSValueScope.CreateHandleScope()`. A nested napi handle scope; JS values + created within it are released when it is disposed, unless held by a `JSReference`. Use it to + bound the lifetime of values created in a loop. +- **Escapable scope** — `JSValueScope.CreateEscapableScope()`. Like a handle scope, but one value + may be promoted to the parent scope with `Escape`, so it survives the inner scope's disposal. + +A **module boundary** is a runtime-context scope that starts a *fresh module holder* while reusing +the surrounding context, so each loaded module resolves its own module instance via +`JSValueScope.Current.Module`. This matters when a single managed host loads several generated +modules: without a fresh holder per module, the most recently loaded module's instance would be the +one every module's callbacks resolve. + +## Lifetime of `napi_value` and `napi_ref` (`JSValue` / `JSReference`) + +- A `napi_value` (wrapped by [`JSValue`](../features/js-value-scopes)) is valid **only within its + scope**. Using it after the scope closes throws `JSValueScopeClosedException`. Values passed to a + .NET callback belong to that call's scope and become invalid when it returns. +- JS values and scopes have **thread affinity**: they may be accessed only from the JS thread that + owns the environment. Access from another thread throws `JSInvalidThreadAccessException`. To marshal + work back to the JS thread, use the context's synchronization context (see + [JS threading & async](../features/js-threading-async)). +- To keep a value **beyond its scope**, create a [`JSReference`](../features/js-references) (a + `napi_ref`). A strong reference keeps the value alive; a weak one lets it be collected and resolves + to nothing afterward. A `JSReference` is itself owned by a context and released with it. + +### Finalizers and teardown — no JS once the context is disposed + +A finalizer (for a wrapped .NET object, an external, or a reference) may run during normal GC while +the environment is still alive, or while the environment is being torn down. **Once the context is +disposed at environment teardown, calling into JavaScript is forbidden.** Finalizer code in this +library follows two rules: + +1. **Resolve the context from the env**, via `JSRuntimeContext.FromEnv(env)` — never by dereferencing + a finalize hint that may already be freed. If `FromEnv` returns no live context (the slot was + cleared at teardown), the finalizer only frees its own native handle and does no JS work. While the + context is still live, a finalizer action may run — for example `JSValue.CallFinalizeAction` opens a + runtime scope to invoke the user action — so this rule is what keeps teardown itself JS-free. +2. **Never assume ordering** among the env's finalizers. Node drains wrapped-object finalizers in no + guaranteed order, so a finalizer must tolerate the context's slot already being cleared (rule 1). + The instance-data finalizer frees the block only after every context on the env is disposed. + +## See also + +- [Project layers](../NodeApi-Layers) — how the assemblies and namespaces are organized. +- [JS value scopes](../features/js-value-scopes), [JS references](../features/js-references) — + the day-to-day API surface built on this model. +- [JS threading & async](../features/js-threading-async), + [Node worker threads](../features/node-workers) — the threading rules in practice. diff --git a/docs/features/js-value-scopes.md b/docs/features/js-value-scopes.md index de332411..0a25fc59 100644 --- a/docs/features/js-value-scopes.md +++ b/docs/features/js-value-scopes.md @@ -7,9 +7,8 @@ A value is only valid within its scope; if the scope is closed (disposed), then access or use the value will throw [`JSValueScopeClosedException`](../reference/dotnet/Microsoft.JavaScript.NodeApi/JSValueScopeClosedException). -Values received by a .NET method that is a JS callback are associated with a `Callback` -[scope type](../reference/dotnet/Microsoft.JavaScript.NodeApi/JSValueScopeType). When the method -returns, the callback scope is closed and any values in that scope become invalid. +Values received by a .NET method that is a JS callback belong to the current scope for that call. +When the method returns, that scope is closed and any values in it become invalid. ## Nesting and escaping scopes @@ -23,7 +22,7 @@ JSFunction jsFunction = … foreach (string item in array) { - using (var nestedScope = new JSValueScope()) + using (var nestedScope = JSValueScope.CreateHandleScope()) { // Passing a .NET string to JS requires converting it to JSValue. // The conversion is implicit; the explicit cast is for illustration. @@ -44,7 +43,7 @@ public JSValue EscapableScopeExample(JSCallbackArgs args) foreach (string item in array) { - using (var escapableScope = new JSValueScope(JSValueScopeType.Escapable)) + using (var escapableScope = JSValueScope.CreateEscapableScope()) { JSValue result = jsFunction.Call(thisArg: default, (JSValue)item); if (!result.IsUndefined()) diff --git a/examples/hermes-engine/HermesRuntime.cs b/examples/hermes-engine/HermesRuntime.cs index 1e862c99..37df0ede 100644 --- a/examples/hermes-engine/HermesRuntime.cs +++ b/examples/hermes-engine/HermesRuntime.cs @@ -30,7 +30,8 @@ private HermesRuntime(JSDispatcherQueue dispatcherQueue) JSRuntime runtime = HermesApi.Load("hermes.dll"); using HermesConfig tempConfig = new(); hermes_create_runtime((hermes_config)tempConfig, out _runtime).ThrowIfFailed(); - _rootScope = new JSValueScope(JSValueScopeType.Root, (napi_env)this, runtime); + JSRuntimeContext context = JSRuntimeContext.Create((napi_env)this, runtime); + _rootScope = JSValueScope.CreateRuntimeScope((napi_env)this, context); CreatePolyfills(); } @@ -98,7 +99,7 @@ public static explicit operator napi_env(HermesRuntime value) private void CreatePolyfills() { VerifyElseThrow(JSDispatcherQueue.GetForCurrentThread() == _dispatcherQueue); - using var scope = new JSValueScope(); + using var scope = JSValueScope.CreateHandleScope(); // Add global JSValue global = JSValue.Global; diff --git a/src/NodeApi.DotNetHost/JSMarshaller.cs b/src/NodeApi.DotNetHost/JSMarshaller.cs index 25c164b2..8f8e00b8 100644 --- a/src/NodeApi.DotNetHost/JSMarshaller.cs +++ b/src/NodeApi.DotNetHost/JSMarshaller.cs @@ -89,9 +89,9 @@ public JSMarshaller() typeof(JSRuntimeContext).GetStaticProperty(nameof(JSRuntimeContext.Current)) ?? throw new NotImplementedException("JSRuntimeContext.Current"); - private static readonly PropertyInfo s_moduleContext = - typeof(JSModuleContext).GetStaticProperty(nameof(JSModuleContext.Current)) - ?? throw new NotImplementedException("JSModuleContext.Current"); + private static readonly PropertyInfo s_currentScope = + typeof(JSValueScope).GetStaticProperty(nameof(JSValueScope.Current)) + ?? throw new NotImplementedException("JSValueScope.Current"); private static readonly PropertyInfo s_valueItem = typeof(JSValue).GetIndexer(typeof(string)) @@ -1878,22 +1878,22 @@ private IEnumerable BuildThisArgumentExpressions( if (type.GetCustomAttributes().Any()) { - // For a method on a module class, the .NET object is stored in the module context. + // For a method on a module class, the .NET object is the current module instance. // `ThisArg` is ignored for module-level methods. /* - * ObjectType? __this = JSRuntimeContext.Current.Module as ObjectType; + * ObjectType? __this = JSValueScope.Current.Module as ObjectType; * if (__this == null) return JSValue.Undefined; */ - PropertyInfo moduleProperty = typeof(JSModuleContext).GetProperty( - nameof(JSModuleContext.Module)) - ?? throw new NotImplementedException("JSModuleContext.Module"); + PropertyInfo moduleProperty = typeof(JSValueScope).GetProperty( + nameof(JSValueScope.Module)) + ?? throw new NotImplementedException("JSValueScope.Module"); yield return Expression.Assign( thisVariable, Expression.TypeAs( Expression.Property( - Expression.Property(null, s_moduleContext), + Expression.Property(null, s_currentScope), moduleProperty), type)); yield return Expression.IfThen( diff --git a/src/NodeApi.DotNetHost/ManagedHost.cs b/src/NodeApi.DotNetHost/ManagedHost.cs index 402befe6..e8f22aeb 100644 --- a/src/NodeApi.DotNetHost/ManagedHost.cs +++ b/src/NodeApi.DotNetHost/ManagedHost.cs @@ -34,13 +34,15 @@ public sealed class ManagedHost : JSEventEmitter, IDisposable #if !(NETFRAMEWORK || NETSTANDARD) /// - /// Each instance of a managed host uses a separate assembly load context. - /// That way, static data is not shared across multiple host instances. + /// Each instance of a managed host uses a separate assembly load context, so static data is not + /// shared across host instances. It is not collectible: JSInterfaceMarshaller emits interface + /// adapter types with Reflection.Emit, which a collectible load context does not support, so the + /// context cannot be unloaded at teardown (only its resolve handlers are unsubscribed). /// private readonly AssemblyLoadContext _loadContext = new(name: default); #endif - private JSValueScope? _rootScope; + private JSRuntimeContext? _context; /// /// Component that dynamically exports types from loaded assemblies. @@ -80,17 +82,6 @@ public sealed class ManagedHost : JSEventEmitter, IDisposable /// JS object on which the managed host APIs will be exported. public ManagedHost(JSObject exports) { -#if NETFRAMEWORK || NETSTANDARD - AppDomain.CurrentDomain.AssemblyResolve += OnResolvingAssembly; -#else - _loadContext.Resolving += OnResolvingAssembly; - - // It shouldn't be necessary to handle resolve events in the default load context. - // But TypeBuilder (used by JSInterfaceMarshaller) seems to require it when a nuget - // package referenced type is replaced with a system type, as with IAsyncEnumerable. - AssemblyLoadContext.Default.Resolving += OnResolvingAssembly; -#endif - JSValue addListener(JSCallbackArgs args) { AddListener(eventName: (string)args[0], listener: args[1]); @@ -143,6 +134,20 @@ JSValue removeListener(JSCallbackArgs args) { _exportedAssembliesByName.Add(typeof(Console).Assembly.GetName().Name!); } + + // Subscribe the process-wide resolve handlers last, after all fallible construction: a + // constructor that throws is never registered for disposal, so leaving them subscribed + // would root the failed host. +#if NETFRAMEWORK || NETSTANDARD + AppDomain.CurrentDomain.AssemblyResolve += OnResolvingAssembly; +#else + _loadContext.Resolving += OnResolvingAssembly; + + // It shouldn't be necessary to handle resolve events in the default load context. + // But TypeBuilder (used by JSInterfaceMarshaller) seems to require it when a nuget + // package referenced type is replaced with a system type, as with IAsyncEnumerable. + AssemblyLoadContext.Default.Resolving += OnResolvingAssembly; +#endif } public static bool IsTracingEnabled { get; } = @@ -177,10 +182,14 @@ public static unsafe int InitializeModule(string argument) napi_env env = new((nint)ulong.Parse(args[0], NumberStyles.HexNumber)); napi_value exports = new((nint)ulong.Parse(args[1], NumberStyles.HexNumber)); napi_value* pResult = (napi_value*)(nint)ulong.Parse(args[2], NumberStyles.HexNumber); + ManagedHostRegistration* registration = args.Length > 3 ? + (ManagedHostRegistration*)(nint)ulong.Parse(args[3], NumberStyles.HexNumber) : null; #else [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] - public static napi_value InitializeModule(napi_env env, napi_value exports) + public static unsafe napi_value InitializeModule( + napi_env env, napi_value exports, nint registrationPtr) { + ManagedHostRegistration* registration = (ManagedHostRegistration*)registrationPtr; Trace($"> ManagedHost.InitializeModule({env.Handle:X8})"); Trace($" .NET Runtime version: {Environment.Version}"); #endif @@ -198,10 +207,19 @@ public static napi_value InitializeModule(napi_env env, napi_value exports) runtime = new TracingJSRuntime(runtime, trace); } - JSValueScope scope = new(JSValueScopeType.Root, env, runtime); + // The managed host registers its context in the environment instance-data block (at the + // module slot). When hosted, the native host owns that block and its finalizer signals + // environment teardown, so the managed context is a non-owner: it writes its own slot but + // does not claim the finalizer, and is disposed via the registration notification below. + bool hosted = registration != null; + JSRuntimeContext context = new(env, runtime); try { + // CreateRuntimeScope lazily builds the sync context and can throw; keep it in the try so + // a failure disposes the context instead of leaking it and escaping this entry point. + using JSValueScope scope = JSValueScope.CreateRuntimeScope(env, context); + JSObject exportsObject = (JSObject)new JSValue(exports, scope); // Save the require() and import() functions that were passed in by the init script. @@ -219,15 +237,40 @@ public static napi_value InitializeModule(napi_env env, napi_value exports) ManagedHost host = new(exportsObject) { - _rootScope = scope + _context = context }; + // Dispose the host with its environment: as a disposable annotation on the context, the + // host's full Dispose (which unsubscribes the process-wide resolve handlers) runs when + // the context is disposed at environment teardown. Mirrors the native host. + context.SetDisposableAnnotation(host); + + if (hosted) + { + // Root the managed host for the environment lifetime and give the native host a + // native callback to invoke at teardown (never a JS call -- see OnEnvironmentFinalize). + registration->AddonGCHandle = (nint)GCHandle.Alloc(host); +#if !(NETFRAMEWORK || NETSTANDARD) + registration->OnEnvFinalize = + (nint)(delegate* unmanaged[Cdecl])&OnEnvironmentFinalize; +#endif + } + Trace("< ManagedHost.InitializeModule()"); } catch (Exception ex) { Trace($"Failed to load CLR managed host module: {ex}"); - JSError.ThrowError(ex); + try + { + // Throw via the runtime directly: scope creation may have failed, and the disposed + // context below would make a scope-bound JSError's lazy stack getter unusable. + runtime.ThrowError(env, code: null, ex.ToString()); + } + finally + { + context.Dispose(); + } } #if NETFRAMEWORK || NETSTANDARD @@ -238,6 +281,63 @@ public static napi_value InitializeModule(napi_env env, napi_value exports) #endif } +#if !(NETFRAMEWORK || NETSTANDARD) + /// + /// Called natively by the native host when the environment is being torn down. Runs during + /// environment finalization where calling into JavaScript is forbidden, so it touches only + /// managed state. + /// + [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] + private static void OnEnvironmentFinalize(nint addon) => OnEnvironmentFinalizeCore(addon); +#else + /// + /// Called by the native host (through the default AppDomain) when the environment is being + /// torn down. Runs during environment finalization where calling into JavaScript is forbidden, + /// so it touches only managed state. + /// + public static int OnEnvironmentFinalize(string argument) + { + OnEnvironmentFinalizeCore((nint)ulong.Parse(argument, NumberStyles.HexNumber)); + return 0; + } +#endif + + private static void OnEnvironmentFinalizeCore(nint addon) + { + if (addon == default) + { + return; + } + + GCHandle handle = GCHandle.FromIntPtr(addon); + try + { + (handle.Target as ManagedHost)?.DisposeOnEnvironmentFinalize(); + } + catch (Exception ex) + { + Trace($"Failed to dispose managed host on environment finalize: {ex}"); + } + finally + { + handle.Free(); + } + } + + /// + /// Disposes the managed host in response to environment teardown. No JavaScript may be called + /// here; disposing the context marks it disposed (so any late cross-thread post becomes a + /// no-op), disposes the host (a disposable annotation on the context) so its process-wide + /// resolve handlers are unsubscribed, and frees the context's GC handles. The context's + /// references are reclaimed by Node as the environment is torn down. + /// + private void DisposeOnEnvironmentFinalize() + { + JSRuntimeContext? context = _context; + _context = null; + context?.Dispose(); + } + /// /// Resolve references to Node API and other assemblies that loaded assemblies depend on. /// @@ -588,19 +688,32 @@ private JSValue RunWorker(JSCallbackArgs args) } } + private bool _isDisposed; + protected override void Dispose(bool disposing) { + if (_isDisposed) return; + _isDisposed = true; + if (disposing) { - _rootScope?.Dispose(); - _rootScope = null; + // The context disposes this host (a disposable annotation) at teardown, so the + // re-entrant context dispose here is a guarded no-op. Unsubscribe the process-wide + // resolve handlers so a torn-down environment's host is not left rooted by them. + _context?.Dispose(); + _context = null; #if NETFRAMEWORK || NETSTANDARD AppDomain.CurrentDomain.AssemblyResolve -= OnResolvingAssembly; #else AssemblyLoadContext.Default.Resolving -= OnResolvingAssembly; _loadContext.Resolving -= OnResolvingAssembly; - _loadContext.Unload(); + + // A non-collectible load context cannot be unloaded; only unload one created collectible. + if (_loadContext.IsCollectible) + { + _loadContext.Unload(); + } #endif } diff --git a/src/NodeApi.DotNetHost/ManagedHostRegistration.cs b/src/NodeApi.DotNetHost/ManagedHostRegistration.cs new file mode 100644 index 00000000..00869630 --- /dev/null +++ b/src/NodeApi.DotNetHost/ManagedHostRegistration.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Runtime.InteropServices; + +namespace Microsoft.JavaScript.NodeApi.DotNetHost; + +/// +/// Native handshake structure the managed host fills in at initialization, so the native host can +/// keep the managed host alive for the environment lifetime and notify it when the environment is +/// torn down. +/// +/// +/// The layout must exactly match the native host's own copy of this structure (in the NodeApi +/// assembly). Both are two pointer-sized fields, passed by pointer across the native/managed +/// boundary. The native host and managed host run in separate .NET runtimes, so the structure is +/// defined independently in each and only its binary layout is shared. +/// +[StructLayout(LayoutKind.Sequential)] +internal struct ManagedHostRegistration +{ + /// + /// A strong to the managed host, allocated and freed only by managed + /// code. The native host treats it as an opaque pointer. + /// + public nint AddonGCHandle; + + /// + /// A native callback pointer (delegate* unmanaged<nint, void>) the native host + /// invokes at environment teardown, or default when the native host uses another channel + /// (the .NET Framework host invokes the finalize method through the default AppDomain instead). + /// + public nint OnEnvFinalize; +} diff --git a/src/NodeApi.Generator/ModuleGenerator.cs b/src/NodeApi.Generator/ModuleGenerator.cs index d4c3bd87..2ecf5c68 100644 --- a/src/NodeApi.Generator/ModuleGenerator.cs +++ b/src/NodeApi.Generator/ModuleGenerator.cs @@ -24,6 +24,7 @@ public class ModuleGenerator : SourceGenerator, ISourceGenerator { private const string ModuleInitializerClassName = "Module"; private const string ModuleInitializeMethodName = "Initialize"; + private const string ModuleExportsMethodName = "InitializeExports"; private const string ModuleRegisterFunctionName = "napi_register_module_v1"; private readonly JSMarshaller _marshaller = new() @@ -287,26 +288,36 @@ private SourceBuilder GenerateModuleInitializer( s += $"public static class {ModuleInitializerClassName}"; s += "{"; - // The module scope is not disposed after a successful initialization. It becomes - // the parent of callback scopes, allowing the JS runtime instance to be inherited. - s += "private static JSValueScope _moduleScope;"; - - // The unmanaged entrypoint is used only when the AOT-compiled module is loaded. + // The unmanaged entrypoint is used only when the AOT-compiled module is loaded. As the + // root it creates the runtime context; there is no host to resolve it from. s += "#if !NETFRAMEWORK"; s += $"[UnmanagedCallersOnly(EntryPoint = \"{ModuleRegisterFunctionName}\")]"; s += $"public static napi_value _{ModuleInitializeMethodName}(napi_env env, napi_value exports)"; - s += $"{s.Indent}=> {ModuleInitializeMethodName}(env, exports);"; + s += "{"; + s += "JSRuntimeContext context = JSRuntimeContext.Create(env);"; + s += "using var moduleScope = JSValueScope.CreateModuleScope(env, context);"; + s += $"return {ModuleExportsMethodName}(moduleScope, exports);"; + s += "}"; s += "#endif"; s++; - // The main initialization entrypoint is called by the `ManagedHost`, and by the unmanaged entrypoint. + // The main initialization entrypoint is called by the `ManagedHost` that loaded this + // module; the scope resolves the runtime context from that host. s += $"public static napi_value {ModuleInitializeMethodName}(napi_env env, napi_value exports)"; s += "{"; - s += "_moduleScope = new JSValueScope(JSValueScopeType.Module, env, runtime: default);"; + s += "using var moduleScope = JSValueScope.CreateModuleScope(env);"; + s += $"return {ModuleExportsMethodName}(moduleScope, exports);"; + s += "}"; + s++; + + // The shared body builds the exports within the module scope opened by an entrypoint + // above; the scope stays alive through the catch so it can build the JS error. + s += $"private static napi_value {ModuleExportsMethodName}(JSValueScope moduleScope, napi_value exports)"; + s += "{"; s += "try"; s += "{"; - s += "JSRuntimeContext context = _moduleScope.RuntimeContext;"; - s += "JSValue exportsValue = new(exports, _moduleScope);"; + s += "JSRuntimeContext context = moduleScope.RuntimeContext;"; + s += "JSValue exportsValue = new(exports, moduleScope);"; s++; if (moduleInitializer is IMethodSymbol moduleInitializerMethod) @@ -340,7 +351,6 @@ private SourceBuilder GenerateModuleInitializer( s += "{"; s += "System.Console.Error.WriteLine($\"Failed to export module: {ex}\");"; s += "JSError.ThrowError(ex);"; - s += "_moduleScope.Dispose();"; s += "return exports;"; s += "}"; s += "}"; diff --git a/src/NodeApi/DotNetHost/NativeHost.cs b/src/NodeApi/DotNetHost/NativeHost.cs index e925cb19..15a3d43b 100644 --- a/src/NodeApi/DotNetHost/NativeHost.cs +++ b/src/NodeApi/DotNetHost/NativeHost.cs @@ -7,6 +7,7 @@ using System.IO; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using Microsoft.JavaScript.NodeApi.Interop; using Microsoft.JavaScript.NodeApi.Runtime; using static Microsoft.JavaScript.NodeApi.DotNetHost.HostFxr; using static Microsoft.JavaScript.NodeApi.DotNetHost.MSCorEE; @@ -28,9 +29,14 @@ internal unsafe partial class NativeHost : IDisposable private string? _managedHostPath; private ICLRRuntimeHost* _runtimeHost; private hostfxr_handle _hostContextHandle; - private readonly JSValueScope _hostScope; private JSReference? _exports; + // Filled in by the managed host during initialization via the registration struct: a GCHandle + // (owned by the managed runtime) that roots the managed host, and a native callback the native + // host invokes at environment teardown. Both are default until a managed host is initialized. + private nint _addonGCHandle; + private nint _onEnvFinalize; + public static bool IsTracingEnabled { get; } = Environment.GetEnvironmentVariable("NODE_API_TRACE_HOST") == "1"; @@ -194,15 +200,21 @@ public static napi_value InitializeModule(napi_env env, napi_value exports) s_jsRuntime ??= new NodejsRuntime(); - // The native host JSValueScope is not disposed after a successful initialization. It - // becomes the parent of callback scopes, allowing the JS runtime instance to be inherited. - JSValueScope hostScope = new(JSValueScopeType.NoContext, env, s_jsRuntime); + // The native host's context occupies the host instance-data slot, so the initialize()/ + // dispose() callbacks (dispatched later with no parent scope) recover it via FromEnv. + JSRuntimeContext.UseHostContextSlot(); + + // The host owns its context (inline, non-TSFN sync context); the transient scope only + // references it and is opened before the try so the catch can still build a JSValue error. + // The context outlives the scope -- rooted by its instance-data slot, disposed by that + // slot's finalizer (which disposes the NativeHost). + JSRuntimeContext context = new(env, s_jsRuntime, new JSInlineSynchronizationContext()); + using JSValueScope hostScope = JSValueScope.CreateRuntimeScope(env, context); try { - NativeHost host = new(hostScope); + NativeHost host = new(); + context.SetDisposableAnnotation(host); - // Do not use JSModuleBuilder here because it relies on having a current context. - // But the context will be set by the managed host. new JSValue(exports, hostScope).DefineProperties( // The package index.js will invoke the initialize method with the path to // the managed host assembly. @@ -213,7 +225,6 @@ public static napi_value InitializeModule(napi_env env, napi_value exports) string message = $"Failed to load CLR native host module: {ex}"; Trace(message); s_jsRuntime.Throw(env, (napi_value)JSValue.CreateError(null, (JSValue)message)); - hostScope.Dispose(); } Trace("< NativeHost.InitializeModule()"); @@ -221,9 +232,37 @@ public static napi_value InitializeModule(napi_env env, napi_value exports) return exports; } - private NativeHost(JSValueScope hostScope) + [StructLayout(LayoutKind.Sequential)] + private struct ManagedHostRegistration + { + public nint AddonGCHandle; + public nint OnEnvFinalize; + } + + private void NotifyManagedHostEnvironmentFinalize() { - _hostScope = hostScope; + if (_onEnvFinalize != default) + { + // hostfxr (.NET 5+): the managed host provided a native callback pointer. + ((delegate* unmanaged[Cdecl])_onEnvFinalize)(_addonGCHandle); + } + else if (_runtimeHost is not null && _addonGCHandle != default && _managedHostPath is not null) + { + // .NET Framework: invoke the managed finalize through the default AppDomain. This is a + // native call into the (still-loaded) CLR, never a JavaScript call. + try + { + _runtimeHost->ExecuteInDefaultAppDomain( + _managedHostPath, + s_managedHostTypeName, + "OnEnvironmentFinalize", + ((ulong)_addonGCHandle).ToString("X8")); + } + catch (Exception ex) + { + Trace("Failed to notify managed host on environment finalize: " + ex); + } + } } /// @@ -350,8 +389,10 @@ private JSValue InitializeFrameworkHost( napi_value exports = (napi_value)exportsValue; // The method to be executed must take a single string argument and return a uint. - // So, encode the parameters and retval pointer in the argument string. - string argument = $"{(ulong)env.Handle:X8},{(ulong)exports.Handle:X8},{(ulong)&exports:X8}"; + // So, encode the parameters, retval pointer, and registration pointer in the argument. + ManagedHostRegistration registration = default; + string argument = $"{(ulong)env.Handle:X8},{(ulong)exports.Handle:X8}," + + $"{(ulong)&exports:X8},{(ulong)®istration:X8}"; Trace($" Calling {s_managedHostTypeName}.{nameof(InitializeModule)}({argument})"); _runtimeHost->ExecuteInDefaultAppDomain( @@ -360,6 +401,9 @@ private JSValue InitializeFrameworkHost( nameof(InitializeModule), argument); + _addonGCHandle = registration.AddonGCHandle; + _onEnvFinalize = registration.OnEnvFinalize; + exportsValue = exports; return exportsValue; } @@ -446,23 +490,28 @@ private JSValue InitializeDotNetHost( Trace(" Invoking managed host method: " + nameof(InitializeModule)); - // Invoke the managed host initialize method. - // (It will define some properties on the exports object passed in.) - napi_register_module_v1 initializeModule = - Marshal.GetDelegateForFunctionPointer( - initializeModulePointer); - // Create an "exports" object for the managed host module initialization. var exports = JSValue.CreateObject(); exports.SetProperty("require", require); exports.SetProperty("import", import); - // Define a dispose method implemented by the native host that closes the CLR context. - // The managed host proxy will pass through dispose calls to this callback. + // The dispose method runs the full idempotent host disposal -- notifying the managed host + // before closing the runtime-host channel -- so on .NET Framework (which notifies managed + // code only through that channel) the managed registration is released, not stranded. exports.DefineProperties(new JSPropertyDescriptor( "dispose", (_) => { Dispose(); return default; })); - exports = initializeModule((napi_env)exports.Scope, (napi_value)exports); + // Invoke the managed host initialize method. It defines properties on the exports object + // and fills in the registration so the native host can keep the managed host alive and + // notify it when the environment is torn down. + ManagedHostRegistration registration = default; + var initializeModule = + (delegate* unmanaged[Cdecl]) + initializeModulePointer; + exports = initializeModule((napi_env)exports.Scope, (napi_value)exports, (nint)(®istration)); + + _addonGCHandle = registration.AddonGCHandle; + _onEnvFinalize = registration.OnEnvFinalize; return exports; } @@ -498,6 +547,25 @@ private hostfxr_handle InitializeManagedRuntime( public void Dispose() { + // Called at env teardown (disposable annotation on the host context) and by the JS dispose() hook. + NotifyManagedHostEnvironmentFinalize(); + CloseRuntimeHost(); + _addonGCHandle = default; + _onEnvFinalize = default; + + // JSReference.Dispose no-ops once its context is disposed, so this frees the napi_ref only on + // an explicit dispose() (env alive), never during env-teardown finalization. + _exports?.Dispose(); + _exports = null; + } + + private void CloseRuntimeHost() + { + // Closes this environment's CLR host: the hostfxr context handle (.NET 5+) or the + // ICLRRuntimeHost COM reference (.NET Framework). Each environment initializes its own, so + // this is per-environment teardown (the underlying shared CLR is not unloaded). Invoked at + // environment teardown and by the optional JS dispose() hook; idempotent. + // Close the CLR host context handle, if it's still open. if (_hostContextHandle != default) { diff --git a/src/NodeApi/Interop/JSCallbackDescriptor.cs b/src/NodeApi/Interop/JSCallbackDescriptor.cs index 682f0752..e865acf5 100644 --- a/src/NodeApi/Interop/JSCallbackDescriptor.cs +++ b/src/NodeApi/Interop/JSCallbackDescriptor.cs @@ -3,6 +3,7 @@ using System; using System.Diagnostics; +using System.Runtime.CompilerServices; namespace Microsoft.JavaScript.NodeApi.Interop; @@ -15,10 +16,10 @@ namespace Microsoft.JavaScript.NodeApi.Interop; public readonly struct JSCallbackDescriptor { /// - /// Saves the module context under which the callback was defined, so that multiple .NET + /// Saves the module instance holder under which the callback was defined, so that multiple .NET /// modules in the same process can register callbacks for module-level functions. /// - internal JSModuleContext? ModuleContext { get; } + internal StrongBox? ModuleHolder { get; } /// /// Gets the name of the callback, for debugging purposes. @@ -37,27 +38,27 @@ public readonly struct JSCallbackDescriptor public object? Data { get; } public JSCallbackDescriptor(JSCallback callback, object? data = null) - : this(null, callback, data, JSValueScope.Current.ModuleContext) + : this(null, callback, data, JSValueScope.Current.ModuleHolder) { } public JSCallbackDescriptor(string? name, JSCallback callback, object? data = null) - : this(name, callback, data, JSValueScope.Current.ModuleContext) + : this(name, callback, data, JSValueScope.Current.ModuleHolder) { } - internal JSCallbackDescriptor(JSCallback callback, object? data, JSModuleContext? moduleContext) - : this(null, callback, data, moduleContext) + internal JSCallbackDescriptor(JSCallback callback, object? data, StrongBox? moduleHolder) + : this(null, callback, data, moduleHolder) { } internal JSCallbackDescriptor( - string? name, JSCallback callback, object? data, JSModuleContext? moduleContext) + string? name, JSCallback callback, object? data, StrongBox? moduleHolder) { Name = name; Callback = callback ?? throw new ArgumentNullException(nameof(callback)); Data = data; - ModuleContext = moduleContext; + ModuleHolder = moduleHolder; } public static implicit operator JSCallbackDescriptor(JSCallback callback) => new(callback); diff --git a/src/NodeApi/Interop/JSModuleBuilderOfT.cs b/src/NodeApi/Interop/JSModuleBuilderOfT.cs index 072c55af..7fecbb8e 100644 --- a/src/NodeApi/Interop/JSModuleBuilderOfT.cs +++ b/src/NodeApi/Interop/JSModuleBuilderOfT.cs @@ -19,21 +19,32 @@ public JSModuleBuilder() : base(Unwrap) private static new T? Unwrap(JSCallbackArgs _) { - return (T?)JSModuleContext.Current.Module; + return (T?)JSValueScope.Current.Module; } /// /// Exports the built properties to the module exports object. /// /// An object that represents the module instance and is - /// used as the 'this' argument for any non-static methods on the module. If the object - /// implements then it is also registered for disposal when - /// the module is unloaded. + /// used as the 'this' argument for any non-static methods on the module. /// Object to be returned from the module initializer. /// The module exports. public JSValue ExportModule(T module, JSObject exports) { - JSModuleContext.Current.Module = module; + // Write through the holder the descriptors captured, so callbacks bound before the module + // instance existed observe it. + JSValueScope.Current.ModuleHolder!.Value = module; + + // Honor JSModuleAttribute's IDisposable contract. Modules loaded into one host share a + // context, and the module-less path passes the context as the module, so append real + // instances (not the type-keyed annotation, which would collide on typeof(IDisposable)); + // the context disposes itself via its own teardown. + JSRuntimeContext context = JSValueScope.Current.RuntimeContext; + if (module is IDisposable disposable && !ReferenceEquals(module, context)) + { + context.AddModuleDisposable(disposable); + } + exports.DefineProperties(Properties.ToArray()); return exports; } diff --git a/src/NodeApi/Interop/JSModuleContext.cs b/src/NodeApi/Interop/JSModuleContext.cs deleted file mode 100644 index 371e56a4..00000000 --- a/src/NodeApi/Interop/JSModuleContext.cs +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System; - -namespace Microsoft.JavaScript.NodeApi.Interop; - -/// -/// Manages JavaScript interop context for the lifetime of a .NET module. -/// -/// -/// A instance is constructed when the module is loaded and disposed -/// when the module is unloaded. -/// -public sealed class JSModuleContext : IDisposable -{ - /// - /// Gets the current module context. - /// - public static JSModuleContext Current => JSValueScope.Current.ModuleContext - ?? throw new InvalidCastException("No current module context."); - - /// - /// Gets an instance of the class that represents the module, or null if there is no module - /// class. - /// - public object? Module { get; internal set; } - - public bool IsDisposed { get; private set; } - - public void Dispose() - { - if (IsDisposed) return; - - IsDisposed = true; - - if (Module is IDisposable module) - { - module.Dispose(); - } - } -} diff --git a/src/NodeApi/Interop/JSRuntimeContext.cs b/src/NodeApi/Interop/JSRuntimeContext.cs index 2c2388c1..a8ccf52c 100644 --- a/src/NodeApi/Interop/JSRuntimeContext.cs +++ b/src/NodeApi/Interop/JSRuntimeContext.cs @@ -37,8 +37,6 @@ public sealed class JSRuntimeContext : IDisposable /// public const string GlobalObjectName = "node_api_dotnet"; - private readonly napi_env _env; - // Track JS constructors and instance JS wrappers for exported classes, enabling // .NET objects to be automatically wrapped when returned to JS, and re-wrapped as needed // if the (weakly-referenced) JS wrapper has been released. @@ -113,6 +111,32 @@ public sealed class JSRuntimeContext : IDisposable private readonly ConcurrentDictionary _collectionProxyHandlerMap = new(); + // Two buckets so ownership is explicit: DisposableAnnotations are disposed at context teardown; + // Annotations are not. Both are lazy and touched only on the JS thread. + private Dictionary? _annotations; + private Dictionary? _disposableAnnotations; + + // Module instances disposed at context teardown. Unlike the type-keyed annotations, several + // modules share one context, so these are appended rather than keyed by type. + private List? _moduleDisposables; + + // Env instance-data layout: one GCHandle slot per runtime sharing the napi_env. Slot 0 is the + // module context (managed host / AOT module / embedding); slot 1 is the native host context. + // A runtime reads and writes only its own slot, so it never dereferences the other runtime's + // GCHandle (which belongs to a separate GC heap). + private const int ModuleContextSlot = 0; + private const int HostContextSlot = 1; + private const int InstanceDataSlotCount = 2; + + // This runtime's slot in the instance-data block: the module slot by default, or the host slot + // once the native host calls UseHostContextSlot() at startup. + private static int s_instanceDataSlot = ModuleContextSlot; + + // The runtime used to read env instance data in FromEnv, captured when a context registers. A + // JSRuntime is a stateless dispatch v-table, so any registered runtime can read any env's + // instance data; the process-wide static is intentional and safe. + private static JSRuntime? s_instanceDataRuntime; + internal napi_env EnvironmentHandle { get @@ -122,19 +146,74 @@ internal napi_env EnvironmentHandle throw new ObjectDisposedException(nameof(JSRuntimeContext)); } - return _env; + return UncheckedEnvironmentHandle; } } + /// + /// Gets the environment handle without checking whether the context is disposed. For use + /// only where a checked access is unnecessary, such as capturing the env to release a + /// reference on the JS thread (where a disposed context makes the release a safe no-op). + /// + internal napi_env UncheckedEnvironmentHandle { get; } + + /// + /// Gets the GCHandle that roots this context and is stored in its env instance-data slot. It is + /// freed when the context is disposed at env teardown; finalizers resolve the context via + /// rather than this handle, so freeing it leaves nothing dangling. + /// + internal nint ContextHandle { get; } + + /// + /// The managed thread that constructed this context — its environment's JS thread. A runtime + /// scope may be entered only on this thread. + /// + internal int OwningThreadId { get; } + public static explicit operator napi_env(JSRuntimeContext context) { if (context is null) throw new ArgumentNullException(nameof(context)); return context.EnvironmentHandle; } - public static explicit operator JSRuntimeContext(napi_env env) - => JSValue.GetInstanceData(env) as JSRuntimeContext - ?? throw new InvalidCastException("Context is not found in napi_env instance data."); + /// + /// Resolves the for the calling runtime from a napi_env, via the + /// env instance-data block, or null if none is registered. Unlike this + /// does not require a current scope, so callback dispatch can recover the context when no scope + /// is on the thread-static stack yet. + /// + public static unsafe JSRuntimeContext? FromEnv(napi_env env) + { + JSRuntime? runtime = s_instanceDataRuntime; + if (runtime is null) + { + return null; + } + + runtime.GetInstanceData(env, out nint instanceData).ThrowIfFailed(); + if (instanceData == default) + { + return null; + } + + nint slotHandle = ((nint*)instanceData)[s_instanceDataSlot]; + if (slotHandle == default) + { + return null; + } + + // Resolve the context only if it actually belongs to this env. The runtime that reads the + // instance data is a process-wide static, so a stale or foreign registration could point at + // another env's block; a context whose env does not match must not be returned. + JSRuntimeContext? context = GCHandle.FromIntPtr(slotHandle).Target as JSRuntimeContext; + return context is not null && context.UncheckedEnvironmentHandle == env ? context : null; + } + + /// + /// Configures the calling runtime to use the native host's instance-data slot. Called once by + /// the native host at startup; every other runtime keeps the default module slot. + /// + internal static void UseHostContextSlot() => s_instanceDataSlot = HostContextSlot; public bool IsDisposed { get; private set; } @@ -147,7 +226,53 @@ public static explicit operator JSRuntimeContext(napi_env env) public JSRuntime Runtime { get; } - public JSSynchronizationContext SynchronizationContext { get; } + private JSSynchronizationContext? _synchronizationContext; + + /// + /// Gets the synchronization context that marshals callbacks and continuations to the JS thread. + /// A default one is created on first access, which must happen while a scope for this context is + /// current, because creating it captures the current scope's runtime and environment. + /// + public JSSynchronizationContext SynchronizationContext + { + get + { + if (_synchronizationContext is not null) + { + return _synchronizationContext; + } + + // Lazy creation captures the CURRENT scope's env/thread, so it must run only while this + // context is current -- otherwise it would bind this context to a different environment. + if (IsDisposed) + { + throw new ObjectDisposedException(nameof(JSRuntimeContext)); + } + if (JSValueScope.Current.RuntimeContext != this) + { + throw new InvalidOperationException( + "The synchronization context must be created while its runtime context is current."); + } + + return _synchronizationContext = JSSynchronizationContext.Create(); + } + } + + /// + /// Creates a runtime context for a JS environment. Used by AOT module entry points and other + /// embedders that own the environment and therefore create the context rather than resolving + /// it from a host. + /// + /// The JS environment handle. + /// The JS runtime interface; defaults to a . + /// + /// The synchronization context owned by this context; a + /// default one is created when omitted. + public static JSRuntimeContext Create( + napi_env env, + JSRuntime? runtime = null, + JSSynchronizationContext? synchronizationContext = null) + => new(env, runtime ?? new NodejsRuntime(), synchronizationContext); internal JSRuntimeContext( napi_env env, @@ -156,10 +281,103 @@ internal JSRuntimeContext( { if (env.IsNull) throw new ArgumentNullException(nameof(env)); - _env = env; + UncheckedEnvironmentHandle = env; Runtime = runtime; - JSValue.SetInstanceData(env, this); - SynchronizationContext = synchronizationContext ?? JSSynchronizationContext.Create(); + OwningThreadId = Environment.CurrentManagedThreadId; + ContextHandle = (nint)GCHandle.Alloc(this); + try + { + RegisterInstanceData(env, runtime); + } + catch + { + // Registration failed before any caller holds this context to dispose it; free the + // rooting handle so a failed construction leaks nothing (the block, if allocated, is + // freed by RegisterInstanceData). + GCHandle.FromIntPtr(ContextHandle).Free(); + throw; + } + + _synchronizationContext = synchronizationContext; + } + + /// + /// Registers this context in the env instance-data block at this runtime's slot, allocating the + /// block and attaching the teardown finalizer if this runtime is the first to claim the slot. + /// + private unsafe void RegisterInstanceData(napi_env env, JSRuntime runtime) + { + s_instanceDataRuntime = runtime; + + runtime.GetInstanceData(env, out nint instanceData).ThrowIfFailed(); + if (instanceData == default) + { + // One block per env, freed by FinalizeInstanceData when the last context on the env is + // disposed at teardown. + instanceData = Marshal.AllocHGlobal(IntPtr.Size * InstanceDataSlotCount); + for (int i = 0; i < InstanceDataSlotCount; i++) + { + ((nint*)instanceData)[i] = default; + } + + napi_status status = runtime.SetInstanceData( + env, + instanceData, + new napi_finalize(s_finalizeInstanceData), + finalizeHint: default); + if (status != napi_status.napi_ok) + { + // Registration failed, so Node never took ownership of the block; free it here. + Marshal.FreeHGlobal(instanceData); + status.ThrowIfFailed(); + } + } + + ((nint*)instanceData)[s_instanceDataSlot] = ContextHandle; + } + +#if !UNMANAGED_DELEGATES + private static readonly napi_finalize.Delegate s_finalizeInstanceData = FinalizeInstanceData; +#else + private static readonly unsafe delegate* unmanaged[Cdecl] + s_finalizeInstanceData = &FinalizeInstanceData; +#endif + +#if UNMANAGED_DELEGATES + [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] +#endif + private static unsafe void FinalizeInstanceData(napi_env env, nint data, nint hint) + { + // Runs during env teardown, where calling into JS is forbidden. Dispose the owning + // runtime's context (which clears its slot and frees its rooting GCHandle). Only this + // runtime's slot is read, never the other runtime's (whose GCHandle belongs to a separate + // GC heap). + nint slotHandle = ((nint*)data)[s_instanceDataSlot]; + if (slotHandle != default) + { + try + { + (GCHandle.FromIntPtr(slotHandle).Target as JSRuntimeContext)?.Dispose(); + } + catch + { + // A finalizer must never throw; teardown continues regardless. + } + } + + // Free the shared block once the last context on the env is gone (all slots cleared); + // disposing a host context cascades synchronously to the other slot. Do not null it out + // via napi_set_instance_data: that deletes this very TrackedFinalizer, which Node then + // deletes again (double free). + for (int i = 0; i < InstanceDataSlotCount; i++) + { + if (((nint*)data)[i] != default) + { + return; + } + } + + Marshal.FreeHGlobal(data); } /// @@ -695,13 +913,86 @@ public async Task ImportAsync( return value; } + /// + /// Gets a non-owning annotation associated with this context by its type, or null if none. + /// + public T? GetAnnotation() where T : class + => _annotations != null && _annotations.TryGetValue(typeof(T), out object? value) + ? (T)value : null; + + /// + /// Associates a non-owning annotation with this context, keyed by its type. The context never + /// disposes it. + /// + public void SetAnnotation(T value) where T : class + { + if (value is null) throw new ArgumentNullException(nameof(value)); + (_annotations ??= new())[typeof(T)] = value; + } + + /// + /// Gets an owning annotation associated with this context by its type, or null if none. + /// + public T? GetDisposableAnnotation() where T : class, IDisposable + => _disposableAnnotations != null && + _disposableAnnotations.TryGetValue(typeof(T), out IDisposable? value) + ? (T)value : null; + + /// + /// Associates an owning annotation with this context, keyed by its type. The context disposes + /// it when the context itself is disposed (at environment teardown). Replacing an existing + /// annotation of the same type disposes the one being displaced. + /// + /// The context is already disposed, so the value + /// would never be disposed. + public void SetDisposableAnnotation(T value) where T : class, IDisposable + { + if (value is null) throw new ArgumentNullException(nameof(value)); + if (IsDisposed) throw new ObjectDisposedException(nameof(JSRuntimeContext)); + + _disposableAnnotations ??= new(); + if (_disposableAnnotations.TryGetValue(typeof(T), out IDisposable? existing) && + !ReferenceEquals(existing, value)) + { + existing.Dispose(); + } + + _disposableAnnotations[typeof(T)] = value; + } + + /// + /// Registers a module instance to be disposed at environment teardown. Unlike + /// , several modules can share one context, so instances + /// are appended rather than keyed by type, and each is disposed once. + /// + internal void AddModuleDisposable(IDisposable disposable) + { + if (disposable is null) throw new ArgumentNullException(nameof(disposable)); + _moduleDisposables ??= new(); + + // Dedupe by identity, not Equals: a module class may override equality, but each distinct + // instance must be disposed once. + foreach (IDisposable existing in _moduleDisposables) + { + if (ReferenceEquals(existing, disposable)) + { + return; + } + } + + _moduleDisposables.Add(disposable); + } + public void Dispose() { if (IsDisposed) return; IsDisposed = true; - SynchronizationContext.Dispose(); + // Dispose an already-created sync context only; never construct one here. Disposal can run + // during env finalization when no scope is current, and creating a sync context then would + // throw and skip the rest of teardown. + _synchronizationContext?.Dispose(); #if !(NETFRAMEWORK || NETSTANDARD) // ConditionalWeakTable<> is not enumerable in .NET Framework. @@ -711,6 +1002,54 @@ public void Dispose() DisposeReferences(_classMap.Values); DisposeReferences(_staticClassMap.Values); DisposeReferences(_structMap.Values); + + // Disposed after IsDisposed is set, so a late cross-thread post is already a no-op. + if (_moduleDisposables != null) + { + foreach (IDisposable moduleDisposable in _moduleDisposables) + { + try + { + moduleDisposable.Dispose(); + } + catch + { + // A failing module disposal must not prevent the rest of teardown. + } + } + } + + if (_disposableAnnotations != null) + { + foreach (IDisposable annotation in _disposableAnnotations.Values) + { + try + { + annotation.Dispose(); + } + catch + { + // A failing annotation must not prevent the rest of teardown. + } + } + } + + // Remove this context's root so it can be collected: clear its instance-data slot (a + // concurrent FromEnv then resolves no context) and free the rooting GCHandle. The shared + // block itself is freed by FinalizeInstanceData once every context on the env is gone. + if (ContextHandle != default) + { + Runtime.GetInstanceData(UncheckedEnvironmentHandle, out nint instanceData); + if (instanceData != default) + { + unsafe + { + ((nint*)instanceData)[s_instanceDataSlot] = default; + } + } + + GCHandle.FromIntPtr(ContextHandle).Free(); + } } private static void DisposeReferences( diff --git a/src/NodeApi/Interop/JSSynchronizationContext.cs b/src/NodeApi/Interop/JSSynchronizationContext.cs index 48e2463b..e260ba7d 100644 --- a/src/NodeApi/Interop/JSSynchronizationContext.cs +++ b/src/NodeApi/Interop/JSSynchronizationContext.cs @@ -416,3 +416,39 @@ public override void OpenAsyncScope() { } public override void CloseAsyncScope() { } } + +/// +/// A synchronization context that runs work inline when already on the JS thread and drops it +/// otherwise, without a thread-safe function. Used by the native host, which only ever operates +/// on the JS thread and must not stand up a TSFN (which would ref the environment and require an +/// env cleanup hook). +/// +/// +/// Because there is no TSFN to marshal to, work posted from another thread (such as a +/// finalizer running on the GC thread) is dropped rather than +/// scheduled. That is safe for the native host: its references are env-lifetime and reclaimed by +/// Node at teardown, so a dropped off-thread delete never leaves a live reference behind and never +/// touches a dead environment. +/// +internal sealed class JSInlineSynchronizationContext : JSSynchronizationContext +{ + public override void OpenAsyncScope() { } + + public override void CloseAsyncScope() { } + + public override void Post(SendOrPostCallback callback, object? state) + { + if (!IsDisposed && Current == this) + { + callback(state); + } + } + + public override void Send(SendOrPostCallback callback, object? state) + { + if (!IsDisposed && Current == this) + { + callback(state); + } + } +} diff --git a/src/NodeApi/Interop/JSThreadSafeFunction.cs b/src/NodeApi/Interop/JSThreadSafeFunction.cs index a7dbef68..ca7bbb17 100644 --- a/src/NodeApi/Interop/JSThreadSafeFunction.cs +++ b/src/NodeApi/Interop/JSThreadSafeFunction.cs @@ -231,7 +231,7 @@ private static unsafe void CustomCallJS(napi_env env, napi_value jsCallback, nin try { - using JSValueScope scope = new(JSValueScopeType.Callback, env, runtime: null); + using JSValueScope scope = JSValueScope.CreateRuntimeScope(env); object? callbackData = null; if (data != default) @@ -265,7 +265,9 @@ private static unsafe void DefaultCallJS(napi_env env, napi_value jsCallback, ni try { - using JSValueScope scope = new(JSValueScopeType.Callback, env, runtime: null); + // Dispatched on the JS thread; the scope references the context inherited from the + // parent scope, or recovered from env instance data when there is none. + using JSValueScope scope = JSValueScope.CreateRuntimeScope(env); if (data != default) { diff --git a/src/NodeApi/JSError.cs b/src/NodeApi/JSError.cs index 33437ae9..942c4199 100644 --- a/src/NodeApi/JSError.cs +++ b/src/NodeApi/JSError.cs @@ -200,34 +200,28 @@ private static JSValue CreateErrorValueForException(Exception exception, out str JSValue error = (exception as JSException)?.Error?.Value ?? JSValue.CreateError(code: null, (JSValue)message); - // A no-context scope is used when initializing the host. In that case, do not attempt - // to override the stack property, because if initialization fails the scope may not - // be available for the stack callback. - if (JSValueScope.Current.ScopeType != JSValueScopeType.NoContext) + // When running on V8, the `Error.captureStackTrace()` function and `Error.stack` + // property can be used to add the .NET stack info to the JS error stack. + JSValue captureStackTrace = JSValue.Global["Error"]["captureStackTrace"]; + if (captureStackTrace.IsFunction()) { - // When running on V8, the `Error.captureStackTrace()` function and `Error.stack` - // property can be used to add the .NET stack info to the JS error stack. - JSValue captureStackTrace = JSValue.Global["Error"]["captureStackTrace"]; - if (captureStackTrace.IsFunction()) - { - // Capture the stack trace of the .NET exception, which will be combined with - // the JS stack trace when requested. - JSValue dotnetStack = exception.StackTrace?.Replace("\r", string.Empty) - ?? string.Empty; - - // Capture the current JS stack trace as an object. - // Defer formatting the stack as a string until requested. - JSObject jsStack = new(); - captureStackTrace.Call(default, jsStack); - - // Override the `stack` property of the JS Error object, and add private - // properties that the overridden property getter uses to construct the stack. - error.DefineProperties( - JSPropertyDescriptor.AccessorProperty( - "stack", GetErrorStack, setter: null, JSPropertyAttributes.DefaultProperty), - JSPropertyDescriptor.DataProperty("__dotnetStack", dotnetStack), - JSPropertyDescriptor.DataProperty("__jsStack", jsStack)); - } + // Capture the stack trace of the .NET exception, which will be combined with + // the JS stack trace when requested. + JSValue dotnetStack = exception.StackTrace?.Replace("\r", string.Empty) + ?? string.Empty; + + // Capture the current JS stack trace as an object. + // Defer formatting the stack as a string until requested. + JSObject jsStack = new(); + captureStackTrace.Call(default, jsStack); + + // Override the `stack` property of the JS Error object, and add private + // properties that the overridden property getter uses to construct the stack. + error.DefineProperties( + JSPropertyDescriptor.AccessorProperty( + "stack", GetErrorStack, setter: null, JSPropertyAttributes.DefaultProperty), + JSPropertyDescriptor.DataProperty("__dotnetStack", dotnetStack), + JSPropertyDescriptor.DataProperty("__jsStack", jsStack)); } return error; @@ -238,7 +232,7 @@ public readonly void ThrowError() if (_errorRef is null) return; - using var scope = new JSValueScope(JSValueScopeType.Handle); + using var scope = JSValueScope.CreateHandleScope(); if (IsExceptionPending()) throw new JSException(new JSError()); diff --git a/src/NodeApi/JSPropertyDescriptor.cs b/src/NodeApi/JSPropertyDescriptor.cs index a2e34289..3b5e1c20 100644 --- a/src/NodeApi/JSPropertyDescriptor.cs +++ b/src/NodeApi/JSPropertyDescriptor.cs @@ -3,7 +3,7 @@ using System; using System.Diagnostics; -using Microsoft.JavaScript.NodeApi.Interop; +using System.Runtime.CompilerServices; namespace Microsoft.JavaScript.NodeApi; @@ -16,10 +16,10 @@ namespace Microsoft.JavaScript.NodeApi; public readonly struct JSPropertyDescriptor { /// - /// Saves the module context under which the callback was defined, so that multiple .NET + /// Saves the module instance holder under which the callback was defined, so that multiple .NET /// modules in the same process can register callbacks for module-level functions. /// - internal JSModuleContext? ModuleContext { get; init; } + internal StrongBox? ModuleHolder { get; init; } // Either Name or NameValue should be non-null. // NameValue supports non-string property names like symbols. @@ -49,7 +49,7 @@ public JSPropertyDescriptor( JSPropertyAttributes attributes = JSPropertyAttributes.Default, object? data = null) { - ModuleContext = JSValueScope.Current.ModuleContext; + ModuleHolder = JSValueScope.Current.ModuleHolder; Name = name; Method = method; @@ -72,7 +72,7 @@ public JSPropertyDescriptor( JSPropertyAttributes attributes = JSPropertyAttributes.Default, object? data = null) { - ModuleContext = JSValueScope.Current.ModuleContext; + ModuleHolder = JSValueScope.Current.ModuleHolder; NameValue = name; Method = method; diff --git a/src/NodeApi/JSReference.cs b/src/NodeApi/JSReference.cs index 250b0286..40456de4 100644 --- a/src/NodeApi/JSReference.cs +++ b/src/NodeApi/JSReference.cs @@ -5,6 +5,7 @@ using System.Diagnostics.CodeAnalysis; using System.Threading; using Microsoft.JavaScript.NodeApi.Interop; +using Microsoft.JavaScript.NodeApi.Runtime; using static Microsoft.JavaScript.NodeApi.Runtime.JSRuntime; namespace Microsoft.JavaScript.NodeApi; @@ -29,8 +30,7 @@ namespace Microsoft.JavaScript.NodeApi; public class JSReference : IDisposable { private readonly napi_ref _handle; - private readonly napi_env _env; - private readonly JSRuntimeContext? _context; + private readonly JSRuntimeContext _context; /// /// Creates a new instance of a that holds a strong or weak @@ -64,7 +64,6 @@ public JSReference(napi_ref handle, bool isWeak = false) JSValueScope currentScope = JSValueScope.Current; // Thread access to the env will be checked on reference handle use. - _env = currentScope.UncheckedEnvironmentHandle; _handle = handle; _context = currentScope.RuntimeContext; IsWeak = isWeak; @@ -134,7 +133,7 @@ public static bool TryCreateReference( /// accesses the referenced value, if there is a possibility that the current execution /// context is not already on the correct thread. /// - public JSSynchronizationContext? SynchronizationContext => _context?.SynchronizationContext; + public JSSynchronizationContext? SynchronizationContext => _context.SynchronizationContext; private napi_env Env { @@ -142,7 +141,7 @@ private napi_env Env { ThrowIfDisposed(); ThrowIfInvalidThreadAccess(); - return _env; + return _context.UncheckedEnvironmentHandle; } } @@ -321,7 +320,7 @@ private void ThrowIfDisposed() private void ThrowIfInvalidThreadAccess() { JSValueScope currentScope = JSValueScope.Current; - if ((napi_env)currentScope != _env) + if ((napi_env)currentScope != _context.UncheckedEnvironmentHandle) { int threadId = Environment.CurrentManagedThreadId; string? threadName = Thread.CurrentThread.Name; @@ -351,65 +350,44 @@ protected virtual void Dispose(bool disposing) return; } + // Once the context is disposed its napi_env was torn down and Node already reclaimed every + // napi_ref, so there is nothing to delete and touching the env would be unsafe. This single + // flag invalidates all references at once, for both the explicit and finalizer paths. + if (_context.IsDisposed) + { + IsDisposed = true; + return; + } + IsDisposed = true; + // The guard above handles an already-disposed context; if it is disposed concurrently after + // that check, the posted delete is still a safe no-op (the napi_ref went with the env). + napi_env env = _context.UncheckedEnvironmentHandle; + napi_ref handle = _handle; + JSRuntime runtime = _context.Runtime; + if (disposing) { - // Explicit disposal preserves the documented behavior, including asserting that a - // no-context reference is disposed from the JS thread. - if (_context == null) - { - ThrowIfInvalidThreadAccess(); - JSValueScope.CurrentRuntime.DeleteReference(_env, _handle).ThrowIfFailed(); - } - else - { - _context.SynchronizationContext.Post( - () => _context.Runtime.DeleteReference( - _env, _handle).ThrowIfFailed(), allowSync: true); - } + // Delete the reference on the JS thread (inline if already there). + _context.SynchronizationContext.Post( + () => runtime.DeleteReference(env, handle).ThrowIfFailed(), allowSync: true); } else { // The finalizer runs on the GC finalizer thread and MUST NOT throw: an exception // escaping a finalizer terminates the process (observed as a fatal - // JSInvalidThreadAccessException / SIGSEGV during worker-thread teardown). Release the - // native reference only if it can be done without switching threads or asserting an - // active JS scope, and never let an exception propagate. - DisposeFromFinalizer(); - } - } - - private void DisposeFromFinalizer() - { - try - { - if (_context == null) + // JSInvalidThreadAccessException / SIGSEGV during worker-thread teardown). Post the + // delete to the JS thread; the synchronization context is a safe no-op once it (and + // the environment) are gone. + try { - // A no-context reference (for example one created from the native host scope) can - // only be deleted on the JS thread. CurrentOrNull is thread-static, so on the real - // GC finalizer thread it is null and this delete is skipped; the napi_ref is then - // reclaimed when the JS environment is destroyed. The guarded delete still runs if - // Dispose(disposing: false) is ever invoked on the owning JS thread. A no-context - // scope has no synchronization context, so the finalizer cannot marshal the delete - // to the JS thread; doing so would require an env-scoped cleanup queue in the - // native host (tracked as a follow-up). - JSValueScope? scope = JSValueScope.CurrentOrNull; - if (scope != null && scope.UncheckedEnvironmentHandle == _env) - { - scope.Runtime.DeleteReference(_env, _handle); - } - } - else - { - // Post the delete to the JS thread. The synchronization context is a safe no-op - // once it has been disposed (that is, after the worker has been torn down). - _context.SynchronizationContext?.Post( + _context.SynchronizationContext.Post( () => { try { - _context.Runtime.DeleteReference(_env, _handle); + runtime.DeleteReference(env, handle); } catch { @@ -418,10 +396,10 @@ private void DisposeFromFinalizer() }, allowSync: false); } - } - catch - { - // Never allow an exception to escape the finalizer. + catch + { + // Never allow an exception to escape the finalizer. + } } } diff --git a/src/NodeApi/JSValue.cs b/src/NodeApi/JSValue.cs index 52f30e3b..8536f95e 100644 --- a/src/NodeApi/JSValue.cs +++ b/src/NodeApi/JSValue.cs @@ -194,9 +194,7 @@ public static unsafe JSValue CreateFunction( new JSCallbackDescriptor(name, callback, callbackData)); JSValue func = CreateFunction( name, - new napi_callback( - JSValueScope.Current?.ScopeType == JSValueScopeType.NoContext ? - s_invokeJSCallbackNC : s_invokeJSCallback), + new napi_callback(s_invokeJSCallback), (nint)descriptorHandle); func.AddGCHandleFinalizer((nint)descriptorHandle); return func; @@ -230,7 +228,7 @@ public static unsafe JSValue CreateExternal(object value) currentScope.UncheckedEnvironmentHandle, (nint)valueHandle, new napi_finalize(s_finalizeGCHandle), - currentScope.RuntimeContextHandle, + default, out napi_value result) .ThrowIfFailed(result); } @@ -801,9 +799,7 @@ public static unsafe JSValue DefineClass( { GCHandle descriptorHandle = JSRuntimeContext.Current.AllocGCHandle(constructorDescriptor); JSValue? func = null; - napi_callback callback = new( - Current?.ScopeType == JSValueScopeType.NoContext - ? s_invokeJSCallbackNC : s_invokeJSCallback); + napi_callback callback = new(s_invokeJSCallback); nint[] handles = ToUnmanagedPropertyDescriptors( name, propertyDescriptors, (name, descriptorsPtr) => @@ -829,7 +825,7 @@ public unsafe JSValue Wrap(object value) handle, (nint)valueHandle, new napi_finalize(s_finalizeGCHandle), - _scope!.RuntimeContextHandle).ThrowIfFailed(); + default).ThrowIfFailed(); return this; } @@ -848,7 +844,7 @@ public unsafe JSValue Wrap(object value, out JSReference wrapperWeakRef) handle, (nint)valueHandle, new napi_finalize(s_finalizeGCHandle), - _scope!.RuntimeContextHandle, + default, out napi_ref weakRef).ThrowIfFailed(); wrapperWeakRef = new JSReference(weakRef, isWeak: true); return this; @@ -1096,7 +1092,7 @@ public unsafe void AddFinalizer(Action finalize) handle, (nint)finalizeHandle, new napi_finalize(s_callFinalizeAction), - _scope!.RuntimeContextHandle).ThrowIfFailed(); + default).ThrowIfFailed(); } public unsafe void AddFinalizer(Action finalize, out JSReference finalizerRef) @@ -1108,7 +1104,7 @@ public unsafe void AddFinalizer(Action finalize, out JSReference finalizerRef) handle, (nint)finalizeHandle, new napi_finalize(s_callFinalizeAction), - _scope!.RuntimeContextHandle, + default, out napi_ref reference).ThrowIfFailed(); finalizerRef = new JSReference(reference, isWeak: true); } @@ -1163,36 +1159,6 @@ public JSValue GetAllPropertyNames( (napi_key_conversion)conversion, out napi_value result).ThrowIfFailed(result); - //TODO: (vmoroz) What env parameter does here? - //TODO: (vmoroz) Move instance data to somewhere else. It must be not in the public API - internal static unsafe void SetInstanceData(napi_env env, object? data) - { - JSRuntime runtime = CurrentRuntime; - runtime.GetInstanceData(env, out nint handlePtr).ThrowIfFailed(); - if (handlePtr != default) - { - // Current napi_set_instance_data implementation does not call finalizer when we replace existing instance data. - // It means that we only remove the GC root, but do not call Dispose. - GCHandle.FromIntPtr(handlePtr).Free(); - } - - if (data != null) - { - GCHandle handle = GCHandle.Alloc(data); - runtime.SetInstanceData( - env, - (nint)handle, - new napi_finalize(s_finalizeGCHandleToDisposable), - finalizeHint: default).ThrowIfFailed(); - } - } - - internal static object? GetInstanceData(napi_env env) - { - CurrentRuntime.GetInstanceData(env, out nint data).ThrowIfFailed(); - return (data != default) ? GCHandle.FromIntPtr(data).Target : null; - } - public void DetachArrayBuffer() => GetRuntime(out napi_env env, out napi_value handle) .DetachArrayBuffer(env, handle).ThrowIfFailed(); @@ -1218,13 +1184,8 @@ public void Seal() => GetRuntime(out napi_env env, out napi_value handle) internal static readonly napi_callback.Delegate s_invokeJSMethod = InvokeJSMethod; internal static readonly napi_callback.Delegate s_invokeJSGetter = InvokeJSGetter; internal static readonly napi_callback.Delegate s_invokeJSSetter = InvokeJSSetter; - internal static readonly napi_callback.Delegate s_invokeJSCallbackNC = InvokeJSCallbackNoContext; - internal static readonly napi_callback.Delegate s_invokeJSMethodNC = InvokeJSMethodNoContext; - internal static readonly napi_callback.Delegate s_invokeJSGetterNC = InvokeJSGetterNoContext; - internal static readonly napi_callback.Delegate s_invokeJSSetterNC = InvokeJSSetterNoContext; internal static readonly napi_finalize.Delegate s_finalizeGCHandle = FinalizeGCHandle; - internal static readonly napi_finalize.Delegate s_finalizeGCHandleToDisposable = FinalizeGCHandleToDisposable; internal static readonly napi_finalize.Delegate s_finalizeGCHandleToPinnedMemory = FinalizeGCHandleToPinnedMemory; internal static readonly napi_finalize.Delegate s_callFinalizeAction = CallFinalizeAction; #else @@ -1236,19 +1197,9 @@ internal static readonly unsafe delegate* unmanaged[Cdecl] s_invokeJSGetter = &InvokeJSGetter; internal static readonly unsafe delegate* unmanaged[Cdecl] s_invokeJSSetter = &InvokeJSSetter; - internal static readonly unsafe delegate* unmanaged[Cdecl] - s_invokeJSCallbackNC = &InvokeJSCallbackNoContext; - internal static readonly unsafe delegate* unmanaged[Cdecl] - s_invokeJSMethodNC = &InvokeJSMethodNoContext; - internal static readonly unsafe delegate* unmanaged[Cdecl] - s_invokeJSGetterNC = &InvokeJSGetterNoContext; - internal static readonly unsafe delegate* unmanaged[Cdecl] - s_invokeJSSetterNC = &InvokeJSSetterNoContext; internal static readonly unsafe delegate* unmanaged[Cdecl] s_finalizeGCHandle = &FinalizeGCHandle; - internal static readonly unsafe delegate* unmanaged[Cdecl] - s_finalizeGCHandleToDisposable = &FinalizeGCHandleToDisposable; internal static readonly unsafe delegate* unmanaged[Cdecl] s_finalizeGCHandleToPinnedMemory = &FinalizeGCHandleToPinnedMemory; internal static readonly unsafe delegate* unmanaged[Cdecl] @@ -1262,7 +1213,7 @@ internal static unsafe napi_value InvokeJSCallback( napi_env env, napi_callback_info callbackInfo) { return InvokeCallback( - env, callbackInfo, JSValueScopeType.Callback, (descriptor) => descriptor); + env, callbackInfo, (descriptor) => descriptor); } #if UNMANAGED_DELEGATES @@ -1271,11 +1222,11 @@ internal static unsafe napi_value InvokeJSCallback( private static unsafe napi_value InvokeJSMethod(napi_env env, napi_callback_info callbackInfo) { return InvokeCallback( - env, callbackInfo, JSValueScopeType.Callback, (propertyDescriptor) => new( + env, callbackInfo, (propertyDescriptor) => new( propertyDescriptor.Name, propertyDescriptor.Method!, propertyDescriptor.Data, - propertyDescriptor.ModuleContext)); + propertyDescriptor.ModuleHolder)); } #if UNMANAGED_DELEGATES @@ -1284,11 +1235,11 @@ private static unsafe napi_value InvokeJSMethod(napi_env env, napi_callback_info private static unsafe napi_value InvokeJSGetter(napi_env env, napi_callback_info callbackInfo) { return InvokeCallback( - env, callbackInfo, JSValueScopeType.Callback, (propertyDescriptor) => new( + env, callbackInfo, (propertyDescriptor) => new( propertyDescriptor.Name, propertyDescriptor.Getter!, propertyDescriptor.Data, - propertyDescriptor.ModuleContext)); + propertyDescriptor.ModuleHolder)); } #if UNMANAGED_DELEGATES @@ -1297,75 +1248,27 @@ private static unsafe napi_value InvokeJSGetter(napi_env env, napi_callback_info private static napi_value InvokeJSSetter(napi_env env, napi_callback_info callbackInfo) { return InvokeCallback( - env, callbackInfo, JSValueScopeType.Callback, (propertyDescriptor) => new( - propertyDescriptor.Name, - propertyDescriptor.Setter!, - propertyDescriptor.Data, - propertyDescriptor.ModuleContext)); - } - -#if UNMANAGED_DELEGATES - [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] -#endif - internal static unsafe napi_value InvokeJSCallbackNoContext( - napi_env env, napi_callback_info callbackInfo) - { - return InvokeCallback( - env, callbackInfo, JSValueScopeType.NoContext, (descriptor) => descriptor); - } - -#if UNMANAGED_DELEGATES - [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] -#endif - private static unsafe napi_value InvokeJSMethodNoContext(napi_env env, napi_callback_info callbackInfo) - { - return InvokeCallback( - env, callbackInfo, JSValueScopeType.NoContext, (propertyDescriptor) => new( - propertyDescriptor.Name, - propertyDescriptor.Method!, - propertyDescriptor.Data, - propertyDescriptor.ModuleContext)); - } - -#if UNMANAGED_DELEGATES - [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] -#endif - private static unsafe napi_value InvokeJSGetterNoContext(napi_env env, napi_callback_info callbackInfo) - { - return InvokeCallback( - env, callbackInfo, JSValueScopeType.NoContext, (propertyDescriptor) => new( - propertyDescriptor.Name, - propertyDescriptor.Getter!, - propertyDescriptor.Data, - propertyDescriptor.ModuleContext)); - } - -#if UNMANAGED_DELEGATES - [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] -#endif - private static napi_value InvokeJSSetterNoContext(napi_env env, napi_callback_info callbackInfo) - { - return InvokeCallback( - env, callbackInfo, JSValueScopeType.NoContext, (propertyDescriptor) => new( + env, callbackInfo, (propertyDescriptor) => new( propertyDescriptor.Name, propertyDescriptor.Setter!, propertyDescriptor.Data, - propertyDescriptor.ModuleContext)); + propertyDescriptor.ModuleHolder)); } private static unsafe napi_value InvokeCallback( napi_env env, napi_callback_info callbackInfo, - JSValueScopeType scopeType, Func getCallbackDescriptor) { - using var scope = new JSValueScope(scopeType, env, runtime: default); + // The scope references the context inherited from the parent scope, or -- when the native + // host dispatches a callback with no scope on the thread -- recovered from env instance data. + using var scope = JSValueScope.CreateRuntimeScope(env); try { JSCallbackArgs.GetDataAndLength(scope, callbackInfo, out object? data, out int length); Span args = stackalloc napi_value[length]; JSCallbackDescriptor descriptor = getCallbackDescriptor((TDescriptor)data!); - scope.ModuleContext = descriptor.ModuleContext; + scope.ModuleHolder = descriptor.ModuleHolder; return (napi_value)descriptor.Callback( new JSCallbackArgs(scope, callbackInfo, args, descriptor.Data)); } @@ -1381,11 +1284,13 @@ private static unsafe napi_value InvokeCallback( #endif internal static unsafe void FinalizeGCHandle(napi_env env, nint data, nint hint) { + // Resolve the context from the env rather than a finalize hint, so the context's rooting + // GCHandle can be freed at teardown. A null/disposed context means teardown already ran; + // just free the wrapped object's handle. GCHandle handle = GCHandle.FromIntPtr(data); - if (hint != default) + JSRuntimeContext? context = JSRuntimeContext.FromEnv(env); + if (context != null && !context.IsDisposed) { - GCHandle contextHandle = GCHandle.FromIntPtr(hint); - JSRuntimeContext context = (JSRuntimeContext)contextHandle.Target!; context.FreeGCHandle(handle); } else @@ -1394,31 +1299,6 @@ internal static unsafe void FinalizeGCHandle(napi_env env, nint data, nint hint) } } -#if UNMANAGED_DELEGATES - [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] -#endif - internal static unsafe void FinalizeGCHandleToDisposable(napi_env env, nint data, nint hint) - { - GCHandle handle = GCHandle.FromIntPtr(data); - try - { - (handle.Target as IDisposable)?.Dispose(); - } - finally - { - if (hint != default) - { - GCHandle contextHandle = GCHandle.FromIntPtr(hint); - JSRuntimeContext context = (JSRuntimeContext)contextHandle.Target!; - context.FreeGCHandle(handle); - } - else - { - handle.Free(); - } - } - } - #if UNMANAGED_DELEGATES [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] #endif @@ -1443,19 +1323,29 @@ internal static unsafe void FinalizeGCHandleToPinnedMemory(napi_env env, nint da #endif private static unsafe void CallFinalizeAction(napi_env env, nint data, nint hint) { + // Resolve the context from the env rather than a finalize hint (see FinalizeGCHandle). GCHandle gcHandle = GCHandle.FromIntPtr(data); - GCHandle contextHandle = GCHandle.FromIntPtr(hint); - JSRuntimeContext context = (JSRuntimeContext)contextHandle.Target!; + JSRuntimeContext? context = JSRuntimeContext.FromEnv(env); try { - // TODO: [vmoroz] In future we will be not allowed to run JS in finalizers. - // We must remove creation of the scope. - using var scope = new JSValueScope(JSValueScopeType.Callback); - ((Action)gcHandle.Target!)(); + if (context != null && !context.IsDisposed) + { + // TODO: [vmoroz] In future we will be not allowed to run JS in finalizers. + // We must remove creation of the scope. + using var scope = JSValueScope.CreateRuntimeScope(env, context); + ((Action)gcHandle.Target!)(); + } } finally { - context.FreeGCHandle(gcHandle); + if (context != null && !context.IsDisposed) + { + context.FreeGCHandle(gcHandle); + } + else + { + gcHandle.Free(); + } } } @@ -1605,7 +1495,7 @@ public unsafe void AddGCHandleFinalizer(nint finalizeData) handle, finalizeData, new napi_finalize(s_finalizeGCHandle), - Scope.RuntimeContextHandle).ThrowIfFailed(); + default).ThrowIfFailed(); } } @@ -1654,22 +1544,9 @@ private static unsafe nint[] ToUnmanagedPropertyDescriptors( IReadOnlyCollection descriptors, UseUnmanagedDescriptors action) { - napi_callback methodCallback; - napi_callback getterCallback; - napi_callback setterCallback; - if (JSValueScope.Current?.ScopeType == JSValueScopeType.NoContext) - { - // The NativeHost and ManagedHost set up callbacks without a current module context. - methodCallback = new napi_callback(s_invokeJSMethodNC); - getterCallback = new napi_callback(s_invokeJSGetterNC); - setterCallback = new napi_callback(s_invokeJSSetterNC); - } - else - { - methodCallback = new napi_callback(s_invokeJSMethod); - getterCallback = new napi_callback(s_invokeJSGetter); - setterCallback = new napi_callback(s_invokeJSSetter); - } + napi_callback methodCallback = new(s_invokeJSMethod); + napi_callback getterCallback = new(s_invokeJSGetter); + napi_callback setterCallback = new(s_invokeJSSetter); nint[] handlesToFinalize = new nint[descriptors.Count]; int count = descriptors.Count; diff --git a/src/NodeApi/JSValueScope.cs b/src/NodeApi/JSValueScope.cs index 2aabc8e2..7cfc087c 100644 --- a/src/NodeApi/JSValueScope.cs +++ b/src/NodeApi/JSValueScope.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Threading; using Microsoft.JavaScript.NodeApi.Interop; @@ -13,47 +14,23 @@ namespace Microsoft.JavaScript.NodeApi; /// /// Indicates the type of within the hierarchy of scopes. /// -public enum JSValueScopeType +internal enum JSValueScopeType { /// - /// A limited scope without any or . - /// Used by the Node API .NET native host to set up callbacks before the managed host is - /// initialized. + /// References a and marks the call/context boundary for a JS + /// environment. Opens no napi handle scope; it is the scope a falls back + /// to for validity when no handle scope is open. /// - NoContext, + RuntimeContext, /// - /// A parent scope shared by all (non-AOT) .NET modules loaded in the same process. It has - /// a but no . - /// - /// - /// AOT modules do not have any root scope, so each module scope has a separate - /// . - /// - Root, - - /// - /// A scope specific to each module. It inherits the from the root - /// scope, and has a unique . - /// - /// - /// AOT modules do not have any root scope, so each module also has a separate - /// . - /// - Module, - - /// - /// Callback scope within a module; inherits context from the module. - /// - Callback, - - /// - /// Handle scope within a callback; inherits context from the module. + /// Opens a napi handle scope nested within a parent scope, from which it inherits the context. /// Handle, /// - /// Escapable handle scope within a callback; inherits context from the module. + /// Opens an escapable napi handle scope nested within a parent scope, and can escape one value + /// to the parent scope. /// Escapable, } @@ -77,7 +54,7 @@ public sealed class JSValueScope : IDisposable private readonly SynchronizationContext? _previousSyncContext; private readonly nint _scopeHandle; - public JSValueScopeType ScopeType { get; } + internal JSValueScopeType ScopeType { get; } /// /// Gets the current JS value scope. @@ -142,176 +119,150 @@ public static explicit operator napi_env(JSValueScope scope) public JSRuntime Runtime { get; } public JSRuntimeContext RuntimeContext { get; } - internal nint RuntimeContextHandle { get; } internal static JSRuntime CurrentRuntime => Current.Runtime; internal static JSRuntimeContext? CurrentRuntimeContext => CurrentOrNull?.RuntimeContext; - public JSModuleContext? ModuleContext { get; internal set; } + /// + /// Holds the instance of the module class for the current module. It is a shared mutable cell + /// so callback descriptors can capture it during initialization, before the module instance + /// exists, and observe the instance once it is assigned. + /// + internal StrongBox? ModuleHolder { get; set; } /// - /// Creates a new instance of a with a specified scope type. + /// Gets the instance of the module class for the current module, used as the 'this' argument + /// for module-level instance members, or null if there is no module class. /// - /// The type of scope to create; default is - /// . - public JSValueScope(JSValueScopeType scopeType = JSValueScopeType.Handle) - : this(scopeType, env: default, runtime: default) - { - } + public object? Module => ModuleHolder?.Value; /// - /// Creates a new instance of a , which may be a parentless scope - /// with initial environment handle and JS runtime. + /// Creates a scope that references a and marks the call/context + /// boundary for a JS environment. It opens no napi handle scope. /// - /// The type of scope to create. - /// JS environment handle, required only for creating a scope - /// without a parent, otherwise the environment is inherited from the parent scope. - /// JS runtime interface, required only for creating a scope - /// without a parent, otherwise the JS runtime is inherited from the parent scope. - /// Optional synchronization context to use for async - /// operations; if omitted then a default synchronization context is used. - public JSValueScope( - JSValueScopeType scopeType, - napi_env env, - JSRuntime? runtime, - JSSynchronizationContext? synchronizationContext = null) + /// The JS environment handle. + /// The runtime context to reference. When null it is inherited from the + /// parent scope, or recovered from the environment instance data. + public static JSValueScope CreateRuntimeScope( + napi_env env = default, JSRuntimeContext? context = null) + => new(env, context); + + /// + /// Creates a scope that starts a fresh module + /// boundary: it references the same (inherited or supplied) but + /// begins a new module holder, so each loaded module resolves its own module instance via + /// . + /// + /// The JS environment handle. + /// The runtime context to reference. When null it is inherited from the + /// parent scope, or recovered from the environment instance data. + public static JSValueScope CreateModuleScope( + napi_env env = default, JSRuntimeContext? context = null) + => new(env, context, moduleBoundary: true); + + /// + /// Creates a napi handle scope nested within the current scope. JS values created within it + /// are released when it is disposed, unless held by a . + /// + public static JSValueScope CreateHandleScope() => new(JSValueScopeType.Handle); + + /// + /// Creates an escapable napi handle scope nested within the current scope. One value may be + /// promoted to the parent scope with . + /// + public static JSValueScope CreateEscapableScope() => new(JSValueScopeType.Escapable); + + /// + /// Creates a scope that references an existing + /// (it never creates one). When + /// is true it starts a fresh module holder even if the context is inherited from the parent. + /// + private JSValueScope(napi_env env, JSRuntimeContext? context, bool moduleBoundary = false) { - ScopeType = scopeType; + ScopeType = JSValueScopeType.RuntimeContext; + _parentScope = CurrentOrNull; + + // Inherit the parent scope's context, else recover it from the env instance data. + context ??= _parentScope?.RuntimeContext + ?? JSRuntimeContext.FromEnv(env) + ?? throw new InvalidOperationException( + "A runtime context could not be resolved for the scope."); + + // A disposed context's environment is torn down; entering it would call Node-API on a + // dead env, which the scope's own unchecked handle would not catch. + if (context.IsDisposed) + { + throw new ObjectDisposedException(nameof(JSRuntimeContext)); + } - if (scopeType == JSValueScopeType.NoContext) + // A supplied env must match the resolved context — whether passed explicitly (a root + // boundary: host, AOT module, or embedding) or inherited from the parent — otherwise this + // scope would wrap handles from a different environment. + if (!env.IsNull && env != context.UncheckedEnvironmentHandle) { - // A NoContext scope can inherit the env from a parent NoContext scope. - _parentScope = CurrentOrNull; - if (_parentScope != null && _parentScope.ScopeType != JSValueScopeType.NoContext) - { - throw new InvalidOperationException( - "A NoContext scope cannot be created within another type of scope."); - } - - if (env.IsNull) - { - env = _parentScope?._env ?? - throw new ArgumentNullException(nameof(env), "An environment is required."); - } - - runtime ??= _parentScope?.Runtime ?? - throw new ArgumentNullException(nameof(runtime), "A runtime is required."); - - _env = env; - ThreadId = Environment.CurrentManagedThreadId; - Runtime = runtime; + throw new ArgumentException( + "Environment does not match the runtime context.", nameof(env)); + } + + _env = context.UncheckedEnvironmentHandle; + + // A runtime context is bound to the thread that created it (its env's JS thread); entering it + // from another thread would allow napi to be called off that thread. + if (context.OwningThreadId != Environment.CurrentManagedThreadId) + { + throw new JSInvalidThreadAccessException( + _parentScope, + "A runtime context may be entered only on the thread that created it."); + } + + ThreadId = Environment.CurrentManagedThreadId; + Runtime = context.Runtime; + + // A nested runtime scope that continues the parent's context inherits its module holder; a + // module boundary, or a root with a new/explicit context, starts a fresh one so each loaded + // module resolves its own module instance. + ModuleHolder = !moduleBoundary && _parentScope?.RuntimeContext == context + ? _parentScope.ModuleHolder + : new StrongBox(); + + JSValueScope? previousScope = CurrentOrNull; + try + { + CurrentOrNull = this; + RuntimeContext = context; + + _previousSyncContext = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(context.SynchronizationContext); } - else if (scopeType == JSValueScopeType.Root) + catch (Exception) { - _parentScope = CurrentOrNull; - if (_parentScope != null) - { - if (_parentScope.ScopeType == JSValueScopeType.Root) - { - // When there are multiple instances of the managed host in a process - // (created by separate workers), they do not inherit scope. - _parentScope = null; - } - else - { - throw new InvalidOperationException( - "A Root scope cannot be created within another scope."); - } - } - - if (env.IsNull) - { - throw new ArgumentNullException( - nameof(env), "An environment is required for a root scope."); - } - else if (runtime == null) - { - throw new ArgumentNullException( - nameof(runtime), "A runtime is required for a root scope."); - } - - _env = env; - ThreadId = Environment.CurrentManagedThreadId; - Runtime = runtime; + CurrentOrNull = previousScope; + throw; } - else + } + + /// + /// Creates a or + /// scope that opens a napi handle scope nested within the current scope. + /// + private JSValueScope(JSValueScopeType scopeType) + { + ScopeType = scopeType; + _parentScope = CurrentOrNull ?? throw new InvalidOperationException( + $"A {scopeType} scope cannot be created without a parent scope."); + + if (_parentScope.IsDisposed) { - _parentScope = CurrentOrNull; - - if (scopeType == JSValueScopeType.Module && - _parentScope != null && _parentScope.ScopeType == JSValueScopeType.Module) - { - // When there are multiple AOT modules in a process, they do not inherit scope. - _parentScope = null; - } - - if (_parentScope == null) - { - // Module scopes may be created without a parent scope (for AOT modules). - if (scopeType != JSValueScopeType.Module) - { - throw new InvalidOperationException( - $"A {scopeType} scope cannot be created without a parent scope."); - } - - // AOT module scopes are constructed with an env parameter - // but without a pre-initialized runtime. - _env = env.IsNull ? throw new ArgumentNullException(nameof(env)) : env; - ThreadId = Environment.CurrentManagedThreadId; - Runtime = runtime ?? new NodejsRuntime(); - } - else if (_parentScope.IsDisposed) - { - // This should never happen because disposing a scope removes it from - // s_currentScope (which is used to initialize _parentScope above). - throw new InvalidOperationException("Parent scope is disposed."); - } - else if (scopeType == JSValueScopeType.Callback && - _parentScope.ScopeType != JSValueScopeType.Callback && - _parentScope.ScopeType != JSValueScopeType.Module && - _parentScope.ScopeType != JSValueScopeType.Root && - _parentScope.ScopeType != JSValueScopeType.NoContext) - { - throw new InvalidOperationException( - $"A Callback scope must be created within a Root, Module, or Callback scope. " + - $"Current scope: {scopeType}"); - } - else if (!env.IsNull && env != _parentScope._env) - { - throw new ArgumentException( - "Environment must not be provided for a non-root scope.", - nameof(env)); - } - else if (runtime != null && runtime != _parentScope.Runtime) - { - throw new ArgumentException( - "Runtime must not be provided for a non-root scope.", - nameof(runtime)); - } - else - { - _parentScope.ThrowIfInvalidThreadAccess(); - _env = _parentScope._env; - ThreadId = _parentScope.ThreadId; - Runtime = _parentScope.Runtime; - } - - if (scopeType == JSValueScopeType.Module) - { - if (_parentScope?.ModuleContext != null) - { - throw new InvalidOperationException("Module scope cannot be nested."); - } - - ModuleContext = new JSModuleContext(); - } - else - { - ModuleContext = _parentScope!.ModuleContext; - } + throw new InvalidOperationException("Parent scope is disposed."); } - _scopeHandle = ScopeType switch + _parentScope.ThrowIfInvalidThreadAccess(); + _env = _parentScope._env; + ThreadId = _parentScope.ThreadId; + Runtime = _parentScope.Runtime; + ModuleHolder = _parentScope.ModuleHolder; + + _scopeHandle = scopeType switch { JSValueScopeType.Handle => Runtime.OpenHandleScope(_env, out napi_handle_scope handleScope) @@ -320,39 +271,15 @@ public JSValueScope( => Runtime.OpenEscapableHandleScope( _env, out napi_escapable_handle_scope handleScope) .ThrowIfFailed(handleScope).Handle, - _ => default, + _ => throw new ArgumentException( + $"Invalid handle scope type: {scopeType}", nameof(scopeType)), }; JSValueScope? previousScope = CurrentOrNull; try { CurrentOrNull = this; - - if (scopeType == JSValueScopeType.NoContext) - { - // NoContext scopes do not have a runtime context. - RuntimeContext = null!; - RuntimeContextHandle = default; - } - else if (_parentScope?.RuntimeContext != null) - { - // Nested scopes inherit the runtime context from the parent scope. - RuntimeContext = _parentScope.RuntimeContext; - RuntimeContextHandle = _parentScope.RuntimeContextHandle; - } - else - { - // Unparented scopes initialize a new runtime context. - RuntimeContext = new JSRuntimeContext(env, Runtime, synchronizationContext); - RuntimeContextHandle = (nint)GCHandle.Alloc(RuntimeContext); - } - - if (scopeType == JSValueScopeType.Root || scopeType == JSValueScopeType.Callback) - { - _previousSyncContext = SynchronizationContext.Current; - SynchronizationContext.SetSynchronizationContext( - RuntimeContext.SynchronizationContext); - } + RuntimeContext = _parentScope.RuntimeContext; } catch (Exception) { @@ -366,24 +293,23 @@ public void Dispose() if (IsDisposed) return; IsDisposed = true; - if (ScopeType != JSValueScopeType.NoContext) + switch (ScopeType) { - napi_env env = RuntimeContext.EnvironmentHandle; - - switch (ScopeType) - { - case JSValueScopeType.Handle: - Runtime.CloseHandleScope( - env, new napi_handle_scope(_scopeHandle)).ThrowIfFailed(); - break; - case JSValueScopeType.Escapable: - Runtime.CloseEscapableHandleScope( - env, new napi_escapable_handle_scope(_scopeHandle)).ThrowIfFailed(); - break; - default: - SynchronizationContext.SetSynchronizationContext(_previousSyncContext); - break; - } + // Fetch the env only where it is used, so disposing a runtime scope after its context + // is torn down does not throw from the checked handle accessor. + case JSValueScopeType.Handle: + Runtime.CloseHandleScope( + RuntimeContext.EnvironmentHandle, + new napi_handle_scope(_scopeHandle)).ThrowIfFailed(); + break; + case JSValueScopeType.Escapable: + Runtime.CloseEscapableHandleScope( + RuntimeContext.EnvironmentHandle, + new napi_escapable_handle_scope(_scopeHandle)).ThrowIfFailed(); + break; + default: + SynchronizationContext.SetSynchronizationContext(_previousSyncContext); + break; } CurrentOrNull = _parentScope; diff --git a/src/NodeApi/NodeApi.csproj b/src/NodeApi/NodeApi.csproj index b6aa76a6..c07735e1 100644 --- a/src/NodeApi/NodeApi.csproj +++ b/src/NodeApi/NodeApi.csproj @@ -23,6 +23,13 @@ true + + + + + + + diff --git a/src/NodeApi/Runtime/NodeEmbedding.cs b/src/NodeApi/Runtime/NodeEmbedding.cs index 7c9b74d1..1f2bba5c 100644 --- a/src/NodeApi/Runtime/NodeEmbedding.cs +++ b/src/NodeApi/Runtime/NodeEmbedding.cs @@ -9,6 +9,7 @@ namespace Microsoft.JavaScript.NodeApi.Runtime; using System.Runtime.CompilerServices; #endif using System.Runtime.InteropServices; +using Microsoft.JavaScript.NodeApi.Interop; using static JSRuntime; using static NodejsRuntime; @@ -352,6 +353,13 @@ internal static unsafe NodeEmbeddingStatus RuntimeConfigureCallbackAdapter( } } + // The embedding invokes these adapters (and opens Node-API scopes) repeatedly for the same + // env; reuse the env's registered context to keep one context per env, instead of leaking a new + // context and overwriting the instance-data slot on each call. The instance-data finalizer + // disposes the context at env teardown. + internal static JSRuntimeContext GetOrCreateContext(napi_env env) + => JSRuntimeContext.FromEnv(env) ?? new JSRuntimeContext(env, JSRuntime); + #if UNMANAGED_DELEGATES [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] #endif @@ -362,7 +370,8 @@ internal static unsafe void RuntimePreloadCallbackAdapter( napi_value process, napi_value require) { - using var jsValueScope = new JSValueScope(JSValueScopeType.Root, env, JSRuntime); + JSRuntimeContext context = GetOrCreateContext(env); + using var jsValueScope = JSValueScope.CreateRuntimeScope(env, context); try { var callback = (PreloadCallback)GCHandle.FromIntPtr(cb_data).Target!; @@ -386,7 +395,8 @@ internal static unsafe napi_value RuntimeLoadingCallbackAdapter( napi_value require, napi_value run_cjs) { - using var jsValueScope = new JSValueScope(JSValueScopeType.Root, env, JSRuntime); + JSRuntimeContext context = GetOrCreateContext(env); + using var jsValueScope = JSValueScope.CreateRuntimeScope(env, context); try { var callback = (LoadingCallback)GCHandle.FromIntPtr(cb_data).Target!; @@ -410,7 +420,8 @@ internal static unsafe void RuntimeLoadedCallbackAdapter( napi_env env, napi_value loading_result) { - using var jsValueScope = new JSValueScope(JSValueScopeType.Root, env, JSRuntime); + JSRuntimeContext context = GetOrCreateContext(env); + using var jsValueScope = JSValueScope.CreateRuntimeScope(env, context); try { var callback = (LoadedCallback)GCHandle.FromIntPtr(cb_data).Target!; @@ -433,7 +444,8 @@ internal static unsafe napi_value ModuleInitializeCallbackAdapter( nint module_name, napi_value exports) { - using var jsValueScope = new JSValueScope(JSValueScopeType.Root, env, JSRuntime); + JSRuntimeContext context = GetOrCreateContext(env); + using var jsValueScope = JSValueScope.CreateRuntimeScope(env, context); try { var callback = (InitializeModuleCallback)GCHandle.FromIntPtr(cb_data).Target!; @@ -501,7 +513,8 @@ internal static unsafe NodeEmbeddingStatus TaskPostCallbackAdapter( #endif internal static unsafe void NodeApiRunCallbackAdapter(nint cb_data, napi_env env) { - using var jsValueScope = new JSValueScope(JSValueScopeType.Root, env, JSRuntime); + JSRuntimeContext context = GetOrCreateContext(env); + using var jsValueScope = JSValueScope.CreateRuntimeScope(env, context); try { var callback = (RunNodeApiCallback)GCHandle.FromIntPtr(cb_data).Target!; diff --git a/src/NodeApi/Runtime/NodeEmbeddingNodeApiScope.cs b/src/NodeApi/Runtime/NodeEmbeddingNodeApiScope.cs index 6513384b..9526b40c 100644 --- a/src/NodeApi/Runtime/NodeEmbeddingNodeApiScope.cs +++ b/src/NodeApi/Runtime/NodeEmbeddingNodeApiScope.cs @@ -4,6 +4,7 @@ namespace Microsoft.JavaScript.NodeApi.Runtime; using System; +using Microsoft.JavaScript.NodeApi.Interop; using static JSRuntime; using static NodejsRuntime; @@ -19,8 +20,8 @@ public NodeEmbeddingNodeApiScope(NodeEmbeddingRuntime runtime) NodeEmbedding.JSRuntime.EmbeddingRuntimeOpenNodeApiScope( runtime.Handle, out _nodeApiScope, out napi_env env) .ThrowIfFailed(); - _valueScope = new JSValueScope( - JSValueScopeType.Root, env, NodeEmbedding.JSRuntime); + JSRuntimeContext context = NodeEmbedding.GetOrCreateContext(env); + _valueScope = JSValueScope.CreateRuntimeScope(env, context); } /// diff --git a/src/NodeApi/Runtime/TracingJSRuntime.cs b/src/NodeApi/Runtime/TracingJSRuntime.cs index e78de8df..1da7695b 100644 --- a/src/NodeApi/Runtime/TracingJSRuntime.cs +++ b/src/NodeApi/Runtime/TracingJSRuntime.cs @@ -377,12 +377,17 @@ private static readonly unsafe delegate* unmanaged[Cdecl] s_traceSetterCallback = &TraceSetterCallback; #endif + // Like InvokeCallback (which these replace when tracing is on), the scope references the context + // inherited from the parent scope, or recovered from env instance data when there is none. + private static JSValueScope CreateCallbackScope(napi_env env) + => JSValueScope.CreateRuntimeScope(env); + #if UNMANAGED_DELEGATES [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] #endif private static unsafe napi_value TraceFunctionCallback(napi_env env, napi_callback_info cbinfo) { - using var scope = new JSValueScope(JSValueScopeType.Callback); + using JSValueScope scope = CreateCallbackScope(env); return ((TracingJSRuntime)scope.Runtime).TraceCallback( scope, cbinfo, (descriptor) => descriptor); } @@ -392,13 +397,13 @@ private static unsafe napi_value TraceFunctionCallback(napi_env env, napi_callba #endif private static unsafe napi_value TraceMethodCallback(napi_env env, napi_callback_info cbinfo) { - using var scope = new JSValueScope(JSValueScopeType.Callback); + using JSValueScope scope = CreateCallbackScope(env); return ((TracingJSRuntime)scope.Runtime).TraceCallback( scope, cbinfo, (propertyDescriptor) => new( propertyDescriptor.Name, propertyDescriptor.Method!, propertyDescriptor.Data, - propertyDescriptor.ModuleContext)); + propertyDescriptor.ModuleHolder)); } #if UNMANAGED_DELEGATES @@ -406,13 +411,13 @@ private static unsafe napi_value TraceMethodCallback(napi_env env, napi_callback #endif private static unsafe napi_value TraceGetterCallback(napi_env env, napi_callback_info cbinfo) { - using var scope = new JSValueScope(JSValueScopeType.Callback); + using JSValueScope scope = CreateCallbackScope(env); return ((TracingJSRuntime)scope.Runtime).TraceCallback( scope, cbinfo, (propertyDescriptor) => new( propertyDescriptor.Name, propertyDescriptor.Getter!, propertyDescriptor.Data, - propertyDescriptor.ModuleContext)); + propertyDescriptor.ModuleHolder)); } #if UNMANAGED_DELEGATES @@ -420,13 +425,13 @@ private static unsafe napi_value TraceGetterCallback(napi_env env, napi_callback #endif private static unsafe napi_value TraceSetterCallback(napi_env env, napi_callback_info cbinfo) { - using var scope = new JSValueScope(JSValueScopeType.Callback); + using JSValueScope scope = CreateCallbackScope(env); return ((TracingJSRuntime)scope.Runtime).TraceCallback( scope, cbinfo, (propertyDescriptor) => new( propertyDescriptor.Name, propertyDescriptor.Setter!, propertyDescriptor.Data, - propertyDescriptor.ModuleContext)); + propertyDescriptor.ModuleHolder)); } /// @@ -443,6 +448,9 @@ public napi_value TraceCallback( throw new InvalidOperationException("Callback data is null.")); JSCallbackDescriptor descriptor = getCallbackDescriptor(data); + // Mirror InvokeCallback: make the module instance available to module-level members. + scope.ModuleHolder = descriptor.ModuleHolder; + Span argsSpan = stackalloc napi_value[length]; JSCallbackArgs args = new(scope, cbinfo, argsSpan, descriptor.Data); diff --git a/test/GCTests.cs b/test/GCTests.cs index dc1b4f04..b6a0765f 100644 --- a/test/GCTests.cs +++ b/test/GCTests.cs @@ -44,7 +44,7 @@ public void GCHandles() // - JSPropertyDescriptor: DotnetClass.toString Assert.Equal(3 + 5, JSRuntimeContext.Current.GCHandleCount); - using JSValueScope innerScope = new(JSValueScopeType.Callback); + using JSValueScope innerScope = JSValueScope.CreateRuntimeScope(); jsCreateInstanceFunction.CallAsStatic(dotnetClass); // Two more handles should have been allocated by the JS create-instance function call. @@ -93,7 +93,7 @@ public void GCObjects() Assert.Equal(8, JSRuntimeContext.Current.GCHandleCount); - using (JSValueScope innerScope = new(JSValueScopeType.Callback)) + using (JSValueScope innerScope = JSValueScope.CreateRuntimeScope()) { jsCreateInstanceFunction.CallAsStatic(dotnetClass); } diff --git a/test/JSReferenceTests.cs b/test/JSReferenceTests.cs index ed994865..49cb89a8 100644 --- a/test/JSReferenceTests.cs +++ b/test/JSReferenceTests.cs @@ -13,20 +13,20 @@ public class JSReferenceTests { private readonly MockJSRuntime _mockRuntime = new(); - private JSValueScope TestScope(JSValueScopeType scopeType) - => TestScope(scopeType, new MockJSRuntime.SynchronizationContext()); + private JSValueScope TestScope() + => TestScope(new MockJSRuntime.SynchronizationContext()); - private JSValueScope TestScope( - JSValueScopeType scopeType, JSSynchronizationContext synchronizationContext) + private JSValueScope TestScope(JSSynchronizationContext synchronizationContext) { napi_env env = new(Environment.CurrentManagedThreadId); - return new(scopeType, env, _mockRuntime, synchronizationContext); + var context = new JSRuntimeContext(env, _mockRuntime, synchronizationContext); + return JSValueScope.CreateRuntimeScope(env, context); } [Fact] public void GetReferenceFromSameScope() { - using JSValueScope rootScope = TestScope(JSValueScopeType.Root); + using JSValueScope rootScope = TestScope(); JSValue value = JSValue.CreateObject(); JSReference reference = new(value); @@ -36,10 +36,10 @@ public void GetReferenceFromSameScope() [Fact] public void GetReferenceFromParentScope() { - using JSValueScope rootScope = TestScope(JSValueScopeType.Root); + using JSValueScope rootScope = TestScope(); JSReference reference; - using (JSValueScope handleScope = new(JSValueScopeType.Handle)) + using (JSValueScope handleScope = JSValueScope.CreateHandleScope()) { JSValue value = JSValue.CreateObject(); reference = new JSReference(value); @@ -51,7 +51,7 @@ public void GetReferenceFromParentScope() [Fact] public void GetReferenceFromDifferentThread() { - using JSValueScope rootScope = TestScope(JSValueScopeType.Root); + using JSValueScope rootScope = TestScope(); JSValue value = JSValue.CreateObject(); JSReference reference = new(value); @@ -66,7 +66,7 @@ public void GetReferenceFromDifferentThread() [Fact] public void GetReferenceFromDifferentRootScope() { - using JSValueScope rootScope1 = TestScope(JSValueScopeType.Root); + using JSValueScope rootScope1 = TestScope(); JSValue value = JSValue.CreateObject(); JSReference reference = new(value); @@ -74,7 +74,7 @@ public void GetReferenceFromDifferentRootScope() // Run in a new thread and establish another root scope there. TestUtils.RunInThread(() => { - using JSValueScope rootScope2 = TestScope(JSValueScopeType.Root); + using JSValueScope rootScope2 = TestScope(); Assert.Throws(() => reference.GetValue()); }).Wait(); } @@ -82,7 +82,7 @@ public void GetReferenceFromDifferentRootScope() [Fact] public void GetWeakReferenceUnavailable() { - using JSValueScope rootScope = TestScope(JSValueScopeType.Root); + using JSValueScope rootScope = TestScope(); JSValue value = JSValue.CreateObject(); var reference = new JSReference(value, isWeak: true); @@ -94,7 +94,7 @@ public void GetWeakReferenceUnavailable() [Fact] public void TryGetWeakReferenceValue() { - using JSValueScope rootScope = TestScope(JSValueScopeType.Root); + using JSValueScope rootScope = TestScope(); JSValue value = JSValue.CreateObject(); JSReference reference = new(value); @@ -105,7 +105,7 @@ public void TryGetWeakReferenceValue() [Fact] public void TryGetWeakReferenceUnavailable() { - using JSValueScope rootScope = TestScope(JSValueScopeType.Root); + using JSValueScope rootScope = TestScope(); JSValue value = JSValue.CreateObject(); var reference = new JSReference(value, isWeak: true); @@ -114,25 +114,6 @@ public void TryGetWeakReferenceUnavailable() Assert.False(reference.TryGetValue(out _)); } - // A reference created from a NoContext scope (as the native host does) has a null runtime - // context, so its finalizer takes the branch that previously asserted thread access. The GC - // finalizer runs on a thread with no JS scope, so that assertion threw - // JSInvalidThreadAccessException out of the finalizer, which terminates the process (the - // reported worker-teardown crash). The finalizer must instead complete without throwing. - [Fact] - public void FinalizeNoContextReferenceFromDifferentThreadDoesNotThrow() - { - using JSValueScope noContextScope = TestScope(JSValueScopeType.NoContext); - - JSValue value = JSValue.CreateObject(); - var reference = new FinalizerTestReference(value); - - // Run on a new thread that has no current scope, simulating the GC finalizer thread. - TestUtils.RunInThread(() => reference.SimulateFinalize()).Wait(); - - Assert.True(reference.IsDisposed); - } - // A reference with a runtime context posts its cleanup to the JS thread instead of deleting it // inline. The finalizer must never throw when it runs on a thread with no current scope, and // the posted delete must actually release the native reference once the JS thread pumps it. @@ -140,7 +121,7 @@ public void FinalizeNoContextReferenceFromDifferentThreadDoesNotThrow() public void FinalizeContextReferenceFromDifferentThreadDoesNotThrow() { var syncContext = new MockJSRuntime.RecordingSynchronizationContext(); - using JSValueScope rootScope = TestScope(JSValueScopeType.Root, syncContext); + using JSValueScope rootScope = TestScope(syncContext); JSValue value = JSValue.CreateObject(); var reference = new FinalizerTestReference(value); @@ -160,20 +141,31 @@ public void FinalizeContextReferenceFromDifferentThreadDoesNotThrow() Assert.False(_mockRuntime.HasReference(handle)); } - // Explicit disposal (disposing: true) preserves the documented behavior of asserting thread - // access for a no-context reference; only the finalizer path is made non-throwing. + // Explicit Dispose() from a thread with no current scope must not throw. The pre-refactor + // no-context path asserted thread access and threw JSInvalidThreadAccessException here; every + // reference is now context-backed, so the delete is posted to the JS thread instead. [Fact] - public void DisposeNoContextReferenceFromDifferentThreadThrows() + public void DisposeReferenceFromDifferentThreadPostsDelete() { - using JSValueScope noContextScope = TestScope(JSValueScopeType.NoContext); + var syncContext = new MockJSRuntime.RecordingSynchronizationContext(); + using JSValueScope rootScope = TestScope(syncContext); JSValue value = JSValue.CreateObject(); - JSReference reference = new(value); + var reference = new JSReference(value); + napi_ref handle = reference.Handle; + Assert.True(_mockRuntime.HasReference(handle)); - TestUtils.RunInThread(() => - { - Assert.Throws(() => reference.Dispose()); - }).Wait(); + TestUtils.RunInThread(() => reference.Dispose()).Wait(); + + Assert.True(reference.IsDisposed); + + // The delete is deferred to the JS thread, not run inline on the disposing thread. + Assert.True(_mockRuntime.HasReference(handle)); + Assert.Equal(1, syncContext.PendingCount); + + // Pumping the sync context runs the posted delete, releasing the native reference. + Assert.Equal(1, syncContext.RunPendingCallbacks()); + Assert.False(_mockRuntime.HasReference(handle)); } // The finalizer invokes the virtual Dispose(bool), so a derived override can throw before or @@ -184,7 +176,7 @@ public void DisposeNoContextReferenceFromDifferentThreadThrows() [Fact] public void FinalizerSwallowsExceptionsFromDerivedDisposeOverride() { - using JSValueScope rootScope = TestScope(JSValueScopeType.Root); + using JSValueScope rootScope = TestScope(); CreateAndAbandonThrowingReference(); diff --git a/test/JSValueScopeTests.cs b/test/JSValueScopeTests.cs index 201d2608..85ccbc61 100644 --- a/test/JSValueScopeTests.cs +++ b/test/JSValueScopeTests.cs @@ -2,6 +2,8 @@ // Licensed under the MIT License. using System; +using System.Runtime.CompilerServices; +using Microsoft.JavaScript.NodeApi.Interop; using Xunit; using static Microsoft.JavaScript.NodeApi.Runtime.JSRuntime; @@ -16,272 +18,176 @@ public class JSValueScopeTests { private readonly MockJSRuntime _mockRuntime = new(); - private JSValueScope TestScope(JSValueScopeType scopeType) + private JSValueScope TestRuntimeScope() { napi_env env = new(Environment.CurrentManagedThreadId); - return new(scopeType, env, _mockRuntime, new MockJSRuntime.SynchronizationContext()); + var context = new JSRuntimeContext( + env, _mockRuntime, new MockJSRuntime.SynchronizationContext()); + return JSValueScope.CreateRuntimeScope(env, context); } [Fact] - public void CreateNoContextScope() + public void CreateRuntimeScope() { - using JSValueScope noContextScope = TestScope(JSValueScopeType.NoContext); - Assert.Null(noContextScope.RuntimeContext); - Assert.Equal(JSValueScopeType.NoContext, JSValueScope.Current.ScopeType); + using JSValueScope runtimeScope = TestRuntimeScope(); + Assert.NotNull(runtimeScope.RuntimeContext); + Assert.Equal(JSValueScopeType.RuntimeContext, JSValueScope.Current.ScopeType); } [Fact] - public void CreateRootScope() + public void CreateNestedRuntimeScope() { - using JSValueScope rootScope = TestScope(JSValueScopeType.Root); - Assert.NotNull(rootScope.RuntimeContext); - Assert.Equal(JSValueScopeType.Root, JSValueScope.Current.ScopeType); - } + using JSValueScope runtimeScope = TestRuntimeScope(); - [Fact] - public void CreateModuleScopeWithinNoContextScope() - { - using JSValueScope noContextScope = TestScope(JSValueScopeType.NoContext); - - using (JSValueScope moduleScope = TestScope(JSValueScopeType.Module)) + using (JSValueScope nestedScope = JSValueScope.CreateRuntimeScope()) { - Assert.NotNull(moduleScope.RuntimeContext); - Assert.Equal(JSValueScopeType.Module, JSValueScope.Current.ScopeType); + Assert.Same(runtimeScope.RuntimeContext, nestedScope.RuntimeContext); + Assert.Equal(JSValueScopeType.RuntimeContext, JSValueScope.Current.ScopeType); } - Assert.Equal(JSValueScopeType.NoContext, JSValueScope.Current.ScopeType); + Assert.Equal(JSValueScopeType.RuntimeContext, JSValueScope.Current.ScopeType); } [Fact] - public void CreateModuleScopeWithinRootScope() + public void CreateHandleScopeWithinRuntimeScope() { - using JSValueScope rootScope = TestScope(JSValueScopeType.Root); + using JSValueScope runtimeScope = TestRuntimeScope(); - using (JSValueScope moduleScope = new(JSValueScopeType.Module)) + using (JSValueScope handleScope = JSValueScope.CreateHandleScope()) { - Assert.NotNull(moduleScope.RuntimeContext); - Assert.Equal(JSValueScopeType.Module, JSValueScope.Current.ScopeType); + Assert.Equal(JSValueScopeType.Handle, JSValueScope.Current.ScopeType); } - Assert.Equal(JSValueScopeType.Root, JSValueScope.Current.ScopeType); - } - - [Fact] - public void CreateModuleScopeWithoutRoot() - { - using JSValueScope moduleScope = TestScope(JSValueScopeType.Module); - Assert.NotNull(moduleScope.RuntimeContext); - Assert.Equal(JSValueScopeType.Module, JSValueScope.Current.ScopeType); + Assert.Equal(JSValueScopeType.RuntimeContext, JSValueScope.Current.ScopeType); } [Fact] - public void CreateCallbackScope() + public void CreateHandleScopeWithinNestedRuntimeScope() { - using JSValueScope moduleScope = TestScope(JSValueScopeType.Module); + using JSValueScope runtimeScope = TestRuntimeScope(); - using (JSValueScope callbackScope = new(JSValueScopeType.Callback)) + using (JSValueScope nestedScope = JSValueScope.CreateRuntimeScope()) { - Assert.NotNull(moduleScope.RuntimeContext); - Assert.Equal(JSValueScopeType.Callback, JSValueScope.Current.ScopeType); + using (JSValueScope handleScope = JSValueScope.CreateHandleScope()) + { + Assert.Equal(JSValueScopeType.Handle, JSValueScope.Current.ScopeType); + } + + Assert.Equal(JSValueScopeType.RuntimeContext, JSValueScope.Current.ScopeType); } - Assert.Equal(JSValueScopeType.Module, JSValueScope.Current.ScopeType); + Assert.Equal(JSValueScopeType.RuntimeContext, JSValueScope.Current.ScopeType); } [Fact] - public void CreateHandleScopeWithinRoot() + public void CreateEscapableScopeWithinRuntimeScope() { - using JSValueScope rootScope = TestScope(JSValueScopeType.Root); + using JSValueScope runtimeScope = TestRuntimeScope(); - using (JSValueScope handleScope = new(JSValueScopeType.Handle)) + using (JSValueScope escapableScope = JSValueScope.CreateEscapableScope()) { - Assert.Equal(JSValueScopeType.Handle, JSValueScope.Current.ScopeType); + Assert.Equal(JSValueScopeType.Escapable, JSValueScope.Current.ScopeType); } - Assert.Equal(JSValueScopeType.Root, JSValueScope.Current.ScopeType); + Assert.Equal(JSValueScopeType.RuntimeContext, JSValueScope.Current.ScopeType); } [Fact] - public void CreateHandleScopeWithinModule() + public void HandleScopeRequiresParentScope() { - using JSValueScope moduleScope = TestScope(JSValueScopeType.Module); + Assert.Throws( + () => JSValueScope.CreateHandleScope()); + Assert.Throws( + () => JSValueScope.CreateEscapableScope()); + } - using (JSValueScope handleScope = new(JSValueScopeType.Handle)) - { - Assert.Equal(JSValueScopeType.Handle, JSValueScope.Current.ScopeType); - } + private sealed class DisposableModule : IDisposable + { + public int DisposeCount { get; private set; } - Assert.Equal(JSValueScopeType.Module, JSValueScope.Current.ScopeType); + public void Dispose() => DisposeCount++; } [Fact] - public void CreateHandleScopeWithinCallback() + public void DisposableModulesShareContextAndAreDisposedOnceAtTeardown() { - using JSValueScope moduleScope = TestScope(JSValueScopeType.Module); + var moduleA = new DisposableModule(); + var moduleB = new DisposableModule(); + + napi_env env = new(Environment.CurrentManagedThreadId); + var context = new JSRuntimeContext( + env, _mockRuntime, new MockJSRuntime.SynchronizationContext()); - using (JSValueScope callbackScope = new(JSValueScopeType.Callback)) + using (JSValueScope.CreateRuntimeScope(env, context)) { - using (JSValueScope handleScope = new(JSValueScopeType.Handle)) + // Two generated modules loaded into one managed host share this context; each opens a + // module-boundary scope and exports its instance. + using (JSValueScope.CreateModuleScope(env)) { - Assert.Equal(JSValueScopeType.Handle, JSValueScope.Current.ScopeType); + new JSModuleBuilder().ExportModule( + moduleA, (JSObject)JSValue.CreateObject()); } - Assert.Equal(JSValueScopeType.Callback, JSValueScope.Current.ScopeType); - } - - Assert.Equal(JSValueScopeType.Module, JSValueScope.Current.ScopeType); - } - - [Fact] - public void CreateEscapableScopeWithinCallback() - { - using JSValueScope moduleScope = TestScope(JSValueScopeType.Module); - - using (JSValueScope callbackScope = new(JSValueScopeType.Callback)) - { - using (JSValueScope escapableScope = new(JSValueScopeType.Escapable)) + using (JSValueScope.CreateModuleScope(env)) { - Assert.Equal(JSValueScopeType.Escapable, JSValueScope.Current.ScopeType); + new JSModuleBuilder().ExportModule( + moduleB, (JSObject)JSValue.CreateObject()); } - Assert.Equal(JSValueScopeType.Callback, JSValueScope.Current.ScopeType); + // Loading the second module must not dispose the first. + Assert.Equal(0, moduleA.DisposeCount); + Assert.Equal(0, moduleB.DisposeCount); } - Assert.Equal(JSValueScopeType.Module, JSValueScope.Current.ScopeType); - } + context.Dispose(); - [Fact] - public void InvalidNoContextScopeNesting() - { - using JSValueScope rootScope = TestScope(JSValueScopeType.Root); - Assert.Throws(() => - { - using JSValueScope noContextScope = new(JSValueScopeType.NoContext); - }); - Assert.Equal(JSValueScopeType.Root, JSValueScope.Current.ScopeType); - - using JSValueScope moduleScope = new(JSValueScopeType.Module); - Assert.Throws(() => - { - using JSValueScope noContextScope = new(JSValueScopeType.NoContext); - }); - Assert.Equal(JSValueScopeType.Module, JSValueScope.Current.ScopeType); - - using JSValueScope callbackScope = new(JSValueScopeType.Callback); - Assert.Throws(() => - { - using JSValueScope noContextScope = new(JSValueScopeType.NoContext); - }); - Assert.Equal(JSValueScopeType.Callback, JSValueScope.Current.ScopeType); - - using JSValueScope handleScope = new(JSValueScopeType.Handle); - Assert.Throws(() => - { - using JSValueScope noContextScope = new(JSValueScopeType.NoContext); - }); - Assert.Equal(JSValueScopeType.Handle, JSValueScope.Current.ScopeType); - - using JSValueScope escapableScope = new(JSValueScopeType.Escapable); - Assert.Throws(() => - { - using JSValueScope noContextScope = new(JSValueScopeType.NoContext); - }); - Assert.Equal(JSValueScopeType.Escapable, JSValueScope.Current.ScopeType); + // Each module instance is disposed exactly once at env teardown. + Assert.Equal(1, moduleA.DisposeCount); + Assert.Equal(1, moduleB.DisposeCount); } - [Fact] - public void InvalidRootContextScopeNesting() + private sealed class EqualDisposable : IDisposable { - using JSValueScope noContextScope = TestScope(JSValueScopeType.NoContext); - Assert.Throws(() => - { - using JSValueScope rootScope = new(JSValueScopeType.Root); - }); - Assert.Equal(JSValueScopeType.NoContext, JSValueScope.Current.ScopeType); - - using JSValueScope moduleScope = TestScope(JSValueScopeType.Module); - Assert.Throws(() => - { - using JSValueScope rootScope = new(JSValueScopeType.Root); - }); - Assert.Equal(JSValueScopeType.Module, JSValueScope.Current.ScopeType); + public int DisposeCount { get; private set; } - using JSValueScope callbackScope = new(JSValueScopeType.Callback); - Assert.Throws(() => - { - using JSValueScope rootScope = new(JSValueScopeType.Root); - }); - Assert.Equal(JSValueScopeType.Callback, JSValueScope.Current.ScopeType); + public void Dispose() => DisposeCount++; - using JSValueScope handleScope = new(JSValueScopeType.Handle); - Assert.Throws(() => - { - using JSValueScope rootScope = new(JSValueScopeType.Root); - }); - Assert.Equal(JSValueScopeType.Handle, JSValueScope.Current.ScopeType); + // All instances compare equal, to prove module disposables dedupe by identity, not Equals. + public override bool Equals(object? obj) => obj is EqualDisposable; - using JSValueScope escapableScope = new(JSValueScopeType.Escapable); - Assert.Throws(() => - { - using JSValueScope rootScope = new(JSValueScopeType.Root); - }); - Assert.Equal(JSValueScopeType.Escapable, JSValueScope.Current.ScopeType); + public override int GetHashCode() => 0; } [Fact] - public void InvalidModuleContextScopeNesting() + public void ModuleDisposablesAreDedupedByIdentityNotEquality() { - using JSValueScope moduleScope = TestScope(JSValueScopeType.Module); - using JSValueScope callbackScope = new(JSValueScopeType.Callback); - Assert.Throws(() => - { - using JSValueScope nestedModuleScope = new(JSValueScopeType.Module); - }); - Assert.Equal(JSValueScopeType.Callback, JSValueScope.Current.ScopeType); - - using JSValueScope handleScope = new(JSValueScopeType.Handle); - Assert.Throws(() => - { - using JSValueScope nestedModuleScope = new(JSValueScopeType.Module); - }); - Assert.Equal(JSValueScopeType.Handle, JSValueScope.Current.ScopeType); + var moduleA = new EqualDisposable(); + var moduleB = new EqualDisposable(); + Assert.True(moduleA.Equals(moduleB)); - using JSValueScope escapableScope = new(JSValueScopeType.Escapable); - Assert.Throws(() => - { - using JSValueScope nestedModuleScope = new(JSValueScopeType.Module); - }); - Assert.Equal(JSValueScopeType.Escapable, JSValueScope.Current.ScopeType); - } + napi_env env = new(Environment.CurrentManagedThreadId); + var context = new JSRuntimeContext( + env, _mockRuntime, new MockJSRuntime.SynchronizationContext()); - [Fact] - public void InvalidCallbackContextScopeNesting() - { - using JSValueScope rootScope = TestScope(JSValueScopeType.Root); + context.AddModuleDisposable(moduleA); + context.AddModuleDisposable(moduleB); + context.AddModuleDisposable(moduleA); // Re-adding the same instance is a no-op. - using JSValueScope handleScope = new(JSValueScopeType.Handle); - Assert.Throws(() => - { - using JSValueScope callbackScope = new(JSValueScopeType.Callback); - }); - Assert.Equal(JSValueScopeType.Handle, JSValueScope.Current.ScopeType); + context.Dispose(); - using JSValueScope escapableScope = new(JSValueScopeType.Escapable); - Assert.Throws(() => - { - using JSValueScope callbackScope = new(JSValueScopeType.Callback); - }); - Assert.Equal(JSValueScopeType.Escapable, JSValueScope.Current.ScopeType); + // Both distinct instances are disposed once, despite comparing equal. + Assert.Equal(1, moduleA.DisposeCount); + Assert.Equal(1, moduleB.DisposeCount); } [Fact] public void AccessValueFromClosedScope() { - using JSValueScope rootScope = TestScope(JSValueScopeType.Root); + using JSValueScope rootScope = TestRuntimeScope(); JSValueScope handleScope; JSValue objectValue; - using (handleScope = new(JSValueScopeType.Handle)) + using (handleScope = JSValueScope.CreateHandleScope()) { objectValue = JSValue.CreateObject(); Assert.True(objectValue.IsObject()); @@ -296,13 +202,13 @@ public void AccessValueFromClosedScope() [Fact] public void AccessPropertyKeyFromClosedScope() { - using JSValueScope rootScope = TestScope(JSValueScopeType.Root); + using JSValueScope rootScope = TestRuntimeScope(); JSValue objectValue = JSValue.CreateObject(); JSValue propertyKey; JSValueScope handleScope; - using (handleScope = new(JSValueScopeType.Handle)) + using (handleScope = JSValueScope.CreateHandleScope()) { propertyKey = "test"; Assert.True(propertyKey.IsString()); @@ -321,7 +227,7 @@ public void AccessPropertyKeyFromClosedScope() [Fact] public void CreateValueFromDifferentThread() { - using JSValueScope rootScope = TestScope(JSValueScopeType.Root); + using JSValueScope rootScope = TestRuntimeScope(); // Run in a new thread which will not have any current scope. TestUtils.RunInThread(() => @@ -337,7 +243,7 @@ public void CreateValueFromDifferentThread() [Fact] public void AccessValueFromDifferentThread() { - using JSValueScope rootScope = TestScope(JSValueScopeType.Root); + using JSValueScope rootScope = TestRuntimeScope(); JSValue objectValue = JSValue.CreateObject(); // Run in a new thread which will not have any current scope. @@ -354,18 +260,181 @@ public void AccessValueFromDifferentThread() [Fact] public void AccessValueFromDifferentRootScope() { - using JSValueScope rootScope1 = TestScope(JSValueScopeType.Root); + using JSValueScope rootScope1 = TestRuntimeScope(); JSValue objectValue = JSValue.CreateObject(); // Run in a new thread and establish another root scope there. TestUtils.RunInThread(() => { - using JSValueScope rootScope2 = TestScope(JSValueScopeType.Root); - Assert.Equal(JSValueScopeType.Root, JSValueScope.Current.ScopeType); + using JSValueScope rootScope2 = TestRuntimeScope(); + Assert.Equal(JSValueScopeType.RuntimeContext, JSValueScope.Current.ScopeType); JSInvalidThreadAccessException ex = Assert.Throws( () => objectValue.IsObject()); Assert.Equal(rootScope2, ex.CurrentScope); Assert.Equal(rootScope1, ex.TargetScope); }).Wait(); } + + [Fact] + public void EnterRuntimeContextFromDifferentThreadThrows() + { + napi_env env = new(Environment.CurrentManagedThreadId); + var context = new JSRuntimeContext( + env, _mockRuntime, new MockJSRuntime.SynchronizationContext()); + + // A runtime context may be entered only on the thread that created it. + TestUtils.RunInThread(() => + { + Assert.Throws( + () => JSValueScope.CreateRuntimeScope(env, context)); + }).Wait(); + } + + [Fact] + public void EnterDisposedRuntimeContextThrows() + { + napi_env env = new(Environment.CurrentManagedThreadId); + var context = new JSRuntimeContext( + env, _mockRuntime, new MockJSRuntime.SynchronizationContext()); + context.Dispose(); + + // A disposed context's environment is torn down, so a scope must not adopt it. + Assert.Throws( + () => JSValueScope.CreateRuntimeScope(env, context)); + } + + [Fact] + public void SynchronizationContextRejectsLazyCreateWhenContextNotCurrent() + { + napi_env env = new(Environment.CurrentManagedThreadId); + var contextA = new JSRuntimeContext(env, _mockRuntime); // no sync context -> lazy + var contextB = new JSRuntimeContext( + env, _mockRuntime, new MockJSRuntime.SynchronizationContext()); + + using (JSValueScope.CreateRuntimeScope(env, contextB)) + { + // contextB is current, so lazily creating contextA's sync context (which would capture + // the current scope's environment) must be rejected. + Assert.Throws(() => contextA.SynchronizationContext); + } + + contextA.Dispose(); + + // After disposal, lazy creation is rejected too. + Assert.Throws(() => contextA.SynchronizationContext); + contextB.Dispose(); + } + + // The module instance is captured through a shared holder: descriptors take the holder during + // initialization (before the instance exists) and observe the instance once dispatch assigns it. + // Nested handle/escapable scopes inherit the same holder, so Current.Module round-trips through it. + [Fact] + public void ModuleInstanceRoundTripsThroughSharedHolder() + { + using JSValueScope runtimeScope = TestRuntimeScope(); + + // The runtime scope mints a holder; the module instance is not assigned yet. + StrongBox holder = JSValueScope.Current.ModuleHolder!; + Assert.NotNull(holder); + Assert.Null(JSValueScope.Current.Module); + + object moduleInstance = new(); + using (JSValueScope handleScope = JSValueScope.CreateHandleScope()) + { + // Inner scopes inherit the same holder instance. + Assert.Same(holder, JSValueScope.Current.ModuleHolder); + + // Assigning through the shared holder (as dispatch does) is visible as Current.Module. + holder.Value = moduleInstance; + Assert.Same(moduleInstance, JSValueScope.Current.Module); + } + + // The instance remains visible in the parent scope after the nested scope closes. + Assert.Same(moduleInstance, JSValueScope.Current.Module); + } + + // An escapable scope promotes one value to its parent so the value stays usable after the inner + // scope closes, while a value that was not escaped becomes invalid once the scope is disposed. + [Fact] + public void EscapableScopeEscapesValue() + { + using JSValueScope rootScope = TestRuntimeScope(); + + JSValue escaped; + JSValue notEscaped; + JSValueScope escapableScope; + using (escapableScope = JSValueScope.CreateEscapableScope()) + { + notEscaped = JSValue.CreateObject(); + escaped = escapableScope.Escape(JSValue.CreateObject()); + + Assert.True(escaped.IsObject()); + Assert.True(notEscaped.IsObject()); + } + + // The escaped value was promoted to the parent scope, so it remains usable. + Assert.True(escapableScope.IsDisposed); + Assert.True(escaped.IsObject()); + + // The value that was not escaped belonged to the now-closed scope. + JSValueScopeClosedException ex = Assert.Throws( + () => notEscaped.IsObject()); + Assert.Equal(escapableScope, ex.Scope); + } + + // With no explicit context and no parent scope, CreateRuntimeScope recovers the context from the + // env instance data (JSRuntimeContext.FromEnv) -- the path the dynamic module entry point relies + // on to resolve the context when no scope is on the thread yet. + [Fact] + public void CreateRuntimeScopeResolvesContextFromEnv() + { + napi_env env = new(Environment.CurrentManagedThreadId); + var context = new JSRuntimeContext( + env, _mockRuntime, new MockJSRuntime.SynchronizationContext()); + + // The context registered itself in the env instance data, so FromEnv resolves it. + Assert.Same(context, JSRuntimeContext.FromEnv(env)); + + using JSValueScope runtimeScope = JSValueScope.CreateRuntimeScope(env); + Assert.Same(context, runtimeScope.RuntimeContext); + Assert.Same(context, JSValueScope.Current.RuntimeContext); + } + + // JSRuntimeContext.Create is the public factory used by AOT entry points and embedders. It uses + // the provided runtime, and a runtime scope over the context resolves it as the current context. + [Fact] + public void CreateRuntimeContextFactoryUsesProvidedRuntime() + { + napi_env env = new(Environment.CurrentManagedThreadId); + JSRuntimeContext context = JSRuntimeContext.Create( + env, _mockRuntime, new MockJSRuntime.SynchronizationContext()); + + Assert.Same(_mockRuntime, context.Runtime); + Assert.False(context.IsDisposed); + + using JSValueScope runtimeScope = JSValueScope.CreateRuntimeScope(env, context); + Assert.Same(context, JSValueScope.Current.RuntimeContext); + Assert.Same(context, JSRuntimeContext.Current); + } + + // A runtime-context scope installs the context's synchronization context as the thread's current + // one for its lifetime (so await continuations marshal back to the JS thread) and restores the + // previously-current one when disposed. + [Fact] + public void RuntimeScopeInstallsAndRestoresSynchronizationContext() + { + System.Threading.SynchronizationContext? previous = + System.Threading.SynchronizationContext.Current; + + napi_env env = new(Environment.CurrentManagedThreadId); + var syncContext = new MockJSRuntime.SynchronizationContext(); + var context = new JSRuntimeContext(env, _mockRuntime, syncContext); + + using (JSValueScope runtimeScope = JSValueScope.CreateRuntimeScope(env, context)) + { + Assert.Same(syncContext, System.Threading.SynchronizationContext.Current); + } + + Assert.Same(previous, System.Threading.SynchronizationContext.Current); + } } diff --git a/test/MockJSRuntime.cs b/test/MockJSRuntime.cs index 39a84a9f..b7df9bee 100644 --- a/test/MockJSRuntime.cs +++ b/test/MockJSRuntime.cs @@ -83,6 +83,30 @@ public override napi_status CloseEscapableHandleScope( return napi_ok; } + public override napi_status EscapeHandle( + napi_env env, + napi_escapable_handle_scope scope, + napi_value escapee, + out napi_value result) + { + // Promote the value to the parent scope by mirroring it under a new handle, mimicking + // napi_escape_handle returning a new value that is valid in the outer scope. + if (!_values.TryGetValue(escapee.Handle, out MockJSValue? mockValue)) + { + result = default; + return napi_invalid_arg; + } + + nint handle = ++s_handleCounter; + _values.Add(handle, new MockJSValue + { + ValueType = mockValue.ValueType, + Value = mockValue.Value, + }); + result = new napi_value(handle); + return napi_ok; + } + public override napi_status CreateString( napi_env env, ReadOnlySpan utf16Str, out napi_value result) { @@ -105,6 +129,10 @@ public override napi_status CreateObject( return napi_ok; } + public override napi_status DefineProperties( + napi_env env, napi_value js_object, ReadOnlySpan properties) + => napi_ok; + public override napi_status GetValueType( napi_env env, napi_value value, out napi_valuetype result) { diff --git a/test/TestBuilder.cs b/test/TestBuilder.cs index 047decf4..8d3ba5cb 100644 --- a/test/TestBuilder.cs +++ b/test/TestBuilder.cs @@ -178,10 +178,10 @@ public static void BuildProject( if (GetNoBuild()) return; string workingDirectory = Path.GetDirectoryName(projectFilePath)!; - if (target != "Publish") - { - WriteCurrentFrameworkGlobalJson(workingDirectory, projectFilePath); - } + + // Pin the SDK per build so a build never inherits a stale per-TFM global.json that another + // TFM's host left in the shared test-case directory. + WriteCurrentFrameworkGlobalJson(workingDirectory, projectFilePath); using StreamWriter logWriter = new(File.Open( logFilePath, FileMode.Create, FileAccess.Write, FileShare.Read)); @@ -213,22 +213,19 @@ public static void BuildProject( WorkingDirectory = workingDirectory, }; - // Prevent nested dotnet invocations from inheriting the current host path from the - // parent dotnet process, which can cause host/runtime mismatches when SDK selection - // rolls forward to a newer major version. - if (Environment.Version.Major != 4) - { - startInfo.Environment.Remove("MSBuildSDKsPath"); - startInfo.Environment.Remove("DOTNET_HOST_PATH"); - startInfo.Environment.Remove("DOTNET_ROOT"); - startInfo.Environment.Remove("DOTNET_ROOT(x86)"); - startInfo.Environment.Remove("DOTNET_ROOT_X86"); - startInfo.Environment.Remove("DOTNET_ROOT(x64)"); - startInfo.Environment.Remove("DOTNET_ROOT_X64"); - startInfo.Environment.Remove("DOTNET_MSBUILD_SDK_RESOLVER_CLI_DIR"); - startInfo.Environment.Remove("DOTNET_MSBUILD_SDK_RESOLVER_SDKS_DIR"); - startInfo.Environment.Remove("DOTNET_MSBUILD_SDK_RESOLVER_SDKS_VER"); - } + // Nested dotnet invocations must not inherit the parent's host/SDK resolver environment, + // or SDK roll-forward to a newer major version causes host/runtime mismatches. A .NET + // Framework (net472) test host inherits these from the outer `dotnet test` as well. + startInfo.Environment.Remove("MSBuildSDKsPath"); + startInfo.Environment.Remove("DOTNET_HOST_PATH"); + startInfo.Environment.Remove("DOTNET_ROOT"); + startInfo.Environment.Remove("DOTNET_ROOT(x86)"); + startInfo.Environment.Remove("DOTNET_ROOT_X86"); + startInfo.Environment.Remove("DOTNET_ROOT(x64)"); + startInfo.Environment.Remove("DOTNET_ROOT_X64"); + startInfo.Environment.Remove("DOTNET_MSBUILD_SDK_RESOLVER_CLI_DIR"); + startInfo.Environment.Remove("DOTNET_MSBUILD_SDK_RESOLVER_SDKS_DIR"); + startInfo.Environment.Remove("DOTNET_MSBUILD_SDK_RESOLVER_SDKS_VER"); logWriter.WriteLine($"dotnet {startInfo.Arguments}"); logWriter.WriteLine($"CWD={workingDirectory}"); diff --git a/test/TestCases/napi-dotnet/worker_teardown_stress.js b/test/TestCases/napi-dotnet/worker_teardown_stress.js new file mode 100644 index 00000000..796d1a01 --- /dev/null +++ b/test/TestCases/napi-dotnet/worker_teardown_stress.js @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Stress variant of worker_teardown.js. It repeatedly creates a Worker that loads the .NET host +// (only inside the worker), waits for it to initialize, then terminates it. Each load initializes +// a native host and a managed host for that worker's environment; terminating the worker tears the +// environment down, which runs the native host's instance-data finalizer. That finalizer notifies +// the managed host to dispose its context, all without calling into JavaScript (the environment is +// being destroyed). Looping exercises that per-environment init/teardown path many times to +// surface teardown-ordering crashes or leaked references (the crash class this guards against). +// +// The workers run one at a time (each is terminated before the next is created), so this never +// holds two host instances at once. As with worker_teardown.js this validates the hosted host +// module and runs under HostedClrTests only (the name contains "worker_teardown", which also +// excludes it from NativeAotTests). + +const assert = require('assert'); +const { Worker, isMainThread, parentPort } = require('worker_threads'); + +const iterations = 8; + +if (isMainThread) { + (async () => { + for (let i = 0; i < iterations; i++) { + const worker = new Worker(__filename); + // Fail the test on any worker error for the worker's full lifetime -- including during + // terminate() -- not just while awaiting readiness (as worker_teardown.js does). + worker.on('error', (err) => { throw err; }); + await new Promise((resolve, reject) => { + worker.once('message', (message) => { + try { + assert.strictEqual(message, 'ready'); + resolve(); + } catch (err) { + reject(err); + } + }); + // A worker that exits before signaling 'ready' without an error would otherwise leave this + // promise pending, letting the test process exit successfully after a failed iteration. + worker.once('exit', (code) => { + reject(new Error(`Worker exited before signaling ready (code ${code}).`)); + }); + }); + await worker.terminate(); + } + + // Keep the process alive briefly so any teardown crash surfaces as a non-zero exit code + // instead of being skipped by an immediate process exit. + setTimeout(() => process.exit(0), 300); + })().catch((err) => { throw err; }); +} else { + // Load the native host ONLY in the worker. + const binding = require('../common').binding; + assert.ok(binding); + parentPort.postMessage('ready'); +}