Improve object safety - #500
Conversation
There was a problem hiding this comment.
Pull request overview
Refactors Node-API object lifetimes around environment-owned runtime contexts and explicit value-scope factories.
Changes:
- Reworks scope, reference, module-holder, and runtime-context ownership.
- Updates hosts, embedding adapters, source generation, and Hermes integration.
- Expands lifetime and worker-teardown testing; updates documentation and dependencies.
Reviewed changes
Copilot reviewed 30 out of 30 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
test/TestCases/napi-dotnet/worker_teardown_stress.js |
Adds repeated worker teardown coverage. |
test/TestBuilder.cs |
Hardens SDK selection for test builds. |
test/MockJSRuntime.cs |
Mocks escapable-handle behavior. |
test/JSValueScopeTests.cs |
Tests the new scope model. |
test/JSReferenceTests.cs |
Updates reference lifetime tests. |
test/GCTests.cs |
Uses runtime-scope factories. |
src/NodeApi/Runtime/TracingJSRuntime.cs |
Migrates traced callbacks to runtime scopes. |
src/NodeApi/Runtime/NodeEmbeddingNodeApiScope.cs |
Creates an embedding runtime context. |
src/NodeApi/Runtime/NodeEmbedding.cs |
Updates embedding callback scopes. |
src/NodeApi/NodeApi.csproj |
Grants internal access to host and tests. |
src/NodeApi/JSValueScope.cs |
Introduces factory-based scope construction. |
src/NodeApi/JSValue.cs |
Removes no-context callback paths. |
src/NodeApi/JSReference.cs |
Makes references context-owned. |
src/NodeApi/JSPropertyDescriptor.cs |
Captures module holders. |
src/NodeApi/JSError.cs |
Adapts error handling to new scopes. |
src/NodeApi/Interop/JSThreadSafeFunction.cs |
Uses context-resolving callback scopes. |
src/NodeApi/Interop/JSSynchronizationContext.cs |
Adds an inline host synchronization context. |
src/NodeApi/Interop/JSRuntimeContext.cs |
Adds environment registration and annotations. |
src/NodeApi/Interop/JSModuleContext.cs |
Removes the former module context. |
src/NodeApi/Interop/JSModuleBuilderOfT.cs |
Stores module instances in holders. |
src/NodeApi/Interop/JSCallbackDescriptor.cs |
Carries module holders through callbacks. |
src/NodeApi/DotNetHost/NativeHost.cs |
Adds managed-host teardown registration. |
src/NodeApi.Generator/ModuleGenerator.cs |
Generates separate AOT and hosted entry paths. |
src/NodeApi.DotNetHost/ManagedHostRegistration.cs |
Defines the host teardown handshake. |
src/NodeApi.DotNetHost/ManagedHost.cs |
Registers and disposes managed contexts. |
src/NodeApi.DotNetHost/JSMarshaller.cs |
Resolves module instances from scopes. |
examples/hermes-engine/HermesRuntime.cs |
Migrates Hermes to scope factories. |
docs/features/js-value-scopes.md |
Documents the new factory API. |
Directory.Packages.props |
Updates Nullability.Source. |
bench/Benchmarks.cs |
Updates benchmark scope creation. |
Suppressed comments (3)
src/NodeApi/Interop/JSRuntimeContext.cs:184
FromEnvuses the runtime from the most recently constructed context process-wide. BecauseCreatepublicly accepts a runtime per context, creating env A with runtime A and then env B with a stateful runtime B makesFromEnv(envA)callruntimeB.GetInstanceData(envA)and potentially return B's context. Store/resolve the runtime per environment, or require the caller's runtime explicitly instead of using this global.
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();
src/NodeApi/Interop/JSRuntimeContext.cs:221
- Lazy creation makes disposal unsafe for a context that never opened a scope.
Dispose()accesses this property, so it attemptsJSSynchronizationContext.Create()while noJSValueScopeis current (including instance-data finalization), throws, and skips the remaining context/annotation cleanup. Disposal should only dispose an already-created_synchronizationContext, without constructing one.
/// <summary>
/// Gets the synchronization context that marshals callbacks and continuations to the JS thread.
/// A default one is created on first access, which happens while a scope for this context is
/// current, because creating it requires the current scope's runtime and environment.
/// </summary>
public JSSynchronizationContext SynchronizationContext
=> _synchronizationContext ??= JSSynchronizationContext.Create();
src/NodeApi/Interop/JSRuntimeContext.cs:278
- This silently overwrites an occupied slot instead of enforcing the promised one-context-per-env invariant. The embedding adapters now construct contexts repeatedly for the same lifecycle/env, so earlier contexts remain rooted while
FromEnvsuddenly resolves the last one; separate AOT addons are worse because the overwritten slot may contain a GCHandle owned by another CLR heap. Reuse/reject an existing registration and provide storage that cannot cross-dereference another runtime's handle.
((nint*)instanceData)[s_instanceDataSlot] = ContextHandle;
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| 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( |
…down - JSValueScope: validate a supplied env against the resolved context on the inherited path; a nested runtime scope inherits the parent's module holder. - TracingJSRuntime: apply the descriptor's module holder to the callback scope (matching InvokeCallback) so module members work under NODE_API_TRACE_RUNTIME. - JSRuntimeContext.Dispose: dispose an already-created sync context only, never construct one during environment finalization. - ManagedHost: register as a per-env disposable annotation so its full Dispose (unsubscribing the process-wide resolve handlers) runs at environment teardown. - NativeHost: close the per-env CLR host at environment teardown; correct the process-level comments on both hosts.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 30 out of 30 changed files in this pull request and generated 2 comments.
Suppressed comments (2)
src/NodeApi/Interop/JSRuntimeContext.cs:278
- This unconditionally replaces an existing context for the same runtime/environment. The five changed Node embedding callback adapters each construct a new context, so earlier contexts (including their synchronization contexts and references) remain live while the environment finalizer disposes only the last one. Reuse the context already registered for the environment, and reject duplicate registration in the factory as a safeguard.
((nint*)instanceData)[s_instanceDataSlot] = ContextHandle;
src/NodeApi/Interop/JSRuntimeContext.cs:904
ContextHandleis intentionally never freed, so this dictionary otherwise keeps every disposedManagedHost/NativeHostannotation strongly reachable forever. Repeated worker creation therefore accumulates disposed hosts and their load-context object graphs. Clear the owning annotations after all values have been disposed.
if (_disposableAnnotations != null)
{
foreach (IDisposable annotation in _disposableAnnotations.Values)
{
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 30 out of 30 changed files in this pull request and generated 1 comment.
Suppressed comments (4)
src/NodeApi/Interop/JSRuntimeContext.cs:123
- The fixed two-slot layout is not actually “one slot per runtime.” Two NativeAOT modules loaded into the same
napi_envrun in separate managed runtimes, but both useModuleContextSlot; the later module overwrites the first module’s opaqueGCHandle, and the first module’s instance-data finalizer then attempts to interpret a handle owned by the other GC heap. This can leak or crash during teardown. The instance-data representation needs per-runtime ownership that does not place multiple runtimes’ handles in the same fixed slot.
// 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;
src/NodeApi/Interop/JSRuntimeContext.cs:136
- This strong
GCHandleis intentionally never freed, so every environment permanently roots itsJSRuntimeContextand everything it still references. The worker stress path creates both native- and managed-host contexts per iteration, making repeated worker teardown a guaranteed process-lifetime managed-memory leak. Keep only teardown-safe finalize-hint state alive as long as necessary, and free the context handle after the environment’s dependent finalizers can no longer use it.
// A GCHandle rooting this context, used both as its env instance-data slot value and as the
// finalize hint for pooled GC handles. It is intentionally never freed: pooled-handle
// finalizers dereference it during env teardown, after this context is already disposed.
src/NodeApi/Interop/JSRuntimeContext.cs:180
FromEnvuses whichever runtime was registered most recently process-wide. After creating contexts with differentJSRuntimeinstances, resolving the earlier environment callsGetInstanceDataon the later runtime; stateful implementations (including the per-instanceMockJSRuntime) therefore return the wrong context or fail. Track the runtime/context per environment instead of storing one global runtime.
public static unsafe JSRuntimeContext? FromEnv(napi_env env)
{
JSRuntime? runtime = s_instanceDataRuntime;
if (runtime is null)
{
src/NodeApi/Interop/JSRuntimeContext.cs:877
- Replacing an owning annotation of the same type drops the previous instance without disposing it or returning it to the caller. Because this API promises that the context owns these values, the previous resource is leaked permanently. Define replacement semantics and either dispose the replaced value or reject duplicate registration.
public void SetDisposableAnnotation<T>(T value) where T : class, IDisposable
{
if (value is null) throw new ArgumentNullException(nameof(value));
(_disposableAnnotations ??= new())[typeof(T)] = value;
- Wrapped-object and action finalizers resolve the context from napi_env (FromEnv) instead of a GCHandle finalize hint, so the context's rooting handle no longer needs to stay rooted. - At teardown the context clears its instance-data slot and frees its rooting GCHandle so it can be collected; the small instance-data block is intentionally kept so a late finalizer's FromEnv resolves no context rather than reading freed memory. - Clarify that the FromEnv runtime static is safe: JSRuntime is a stateless dispatch v-table.
The embedding runtime callbacks and Node-API scopes constructed a new JSRuntimeContext for the env on every invocation, leaking a context and overwriting the env instance-data slot each time. They now resolve the env's registered context (FromEnv) and create one only if absent, so there is a single context per env, disposed by the instance-data finalizer at teardown.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 30 out of 30 changed files in this pull request and generated 1 comment.
Suppressed comments (3)
Previously missed (1) — in code that hasn't changed since the last review.
src/NodeApi.DotNetHost/ManagedHost.cs:213
- In hosted mode this context does not own the instance-data finalizer, but the native host receives the managed teardown callback only after
ManagedHostconstruction succeeds and fillsregistration. If initialization throws before that point, the catch reports the JS error but leaves this context's rootingGCHandleand synchronization context registered forever because the native host has no callback with which to dispose it. Close the scope and dispose the context on the failed-initialization path.
bool hosted = registration != null;
JSRuntimeContext context = new(env, runtime);
using JSValueScope scope = JSValueScope.CreateRuntimeScope(env, context);
src/NodeApi/Interop/JSModuleBuilderOfT.cs:37
- This drops the previous ownership behavior of
JSModuleContext: whenmoduleimplementsIDisposable, it is no longer disposed at module/context teardown. Generated module classes such astest/TestCases/napi-dotnet/ModuleClass.cs:17rely on that contract. Register disposable module instances with the runtime context (without collapsing multiple modules onto one annotation key) so teardown still invokesDispose().
// Write through the holder the descriptors captured, so callbacks bound before the module
// instance existed observe it.
JSValueScope.Current.ModuleHolder!.Value = module;
exports.DefineProperties(Properties.ToArray());
src/NodeApi/Interop/JSRuntimeContext.cs:280
- This assignment silently replaces an existing context in the slot without disposing it or freeing its
ContextHandle, violating the one-context-per-env invariant. The default embedding path already triggers this:RuntimeLoadingCallbackAdapterregisters one context, thenNodeEmbeddingNodeApiScoperegisters another for the same environment, permanently rooting the first context and its GC handles. Resolve/reuse the registered context in embedding callbacks/scopes, and reject or safely handle duplicate registration.
((nint*)instanceData)[s_instanceDataSlot] = ContextHandle;
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 30 out of 30 changed files in this pull request and generated 1 comment.
Suppressed comments (6)
Previously missed (2) — in code that hasn't changed since the last review.
src/NodeApi/JSValueScope.cs:175
- With no parent and the default null
env, this callsFromEnv(default). Once any context has initialized the static runtime, that invokesnapi_get_instance_datawith a null environment instead of rejecting the invalid factory call. Validate that an env was supplied before attempting environment lookup.
// 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.");
src/NodeApi.DotNetHost/ManagedHost.cs:213
- The context is registered and rooted before initialization enters the
try, but the native host receives the registration handle only near the end of the successful path. If initialization throws earlier, the catch returns with no handshake handle, so environment teardown cannot dispose this managed context; its instance-data GCHandle, synchronization context, and any installed resolve handlers remain rooted. Dispose the failed scope and context after reporting the JS error.
bool hosted = registration != null;
JSRuntimeContext context = new(env, runtime);
using JSValueScope scope = JSValueScope.CreateRuntimeScope(env, context);
src/NodeApi/JSReference.cs:365
- This concurrent-disposal assumption is unsafe.
JSTsfnSynchronizationContext.PostchecksIsDisposedand then calls_tsfn.NonBlockingCall, whileDisposecan release the TSFN between those operations. Since reference finalizers now always post here during environment teardown, that race can call a released native TSFN. Gate in-flight calls and close the gate before releasing the TSFN.
// 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).
src/NodeApi/DotNetHost/NativeHost.cs:501
- On .NET Framework, the managed teardown notification uses
_runtimeHost->ExecuteInDefaultAppDomain. This callback closes and nulls_runtimeHostwithout notifying the managed host, so the later environment finalizer skips its notification and leaks_addonGCHandleplus the managed context. Run the full idempotentDispose()path so notification occurs before the CLR host is closed.
exports.DefineProperties(new JSPropertyDescriptor(
"dispose", (_) => { CloseRuntimeHost(); return default; }));
src/NodeApi.Generator/ModuleGenerator.cs:309
- This hosted-module scope inherits the
ModuleHolderfrom the currentManagedHost.LoadModulecallback. Consequently all dynamically loaded generated modules share oneStrongBox; loading a second module overwrites the instance observed by callbacks from the first module. Create a fresh module holder at each module-initialization boundary while continuing to share the runtime context.
s += "using var moduleScope = JSValueScope.CreateRuntimeScope(env);";
s += $"return {ModuleExportsMethodName}(moduleScope, exports);";
src/NodeApi/Interop/JSRuntimeContext.cs:880
- Replacing an owning annotation of the same type drops the previous
IDisposablewithout disposing it, even though this API transfers disposal responsibility to the context. Either reject duplicate keys or dispose the previous value when replacing it so owned resources are not leaked.
public void SetDisposableAnnotation<T>(T value) where T : class, IDisposable
{
if (value is null) throw new ArgumentNullException(nameof(value));
(_disposableAnnotations ??= new())[typeof(T)] = value;
JSModuleAttribute documents that a module class implementing IDisposable is disposed when the module is unloaded. Register the module instance as a disposable annotation on its runtime context so it is disposed at environment teardown, restoring that contract.
Add docs/concepts/runtime-model.md covering the napi_env-per-module relationship, the node::Environment vs napi_env vs isolate/worker distinction (environment cleanup hook vs per-napi_env instance-data finalizer), instance-data slot ownership, the three JSValueScope types, and the rules for holding napi_value/napi_ref safely. Add AGENTS.md with thin CLAUDE.md and .github/copilot-instructions.md pointers, and surface the concepts docs in the site navigation.
SetDisposableAnnotation now throws ObjectDisposedException if called after the context is disposed (the value would otherwise never be disposed), and disposes any same-type annotation it displaces so an owned annotation is never silently leaked.
A generated module's hosted entry point opened a runtime scope that inherited the managed host's module holder, so loading a second module overwrote the first module's instance and later callbacks from the first module resolved the wrong instance. Add JSValueScope.CreateModuleScope, which references the surrounding context but starts a fresh module holder, and use it from the generated module entry points.
The embedding adapters resolve the env's context via FromEnv, which reads instance data through the process-wide static runtime. When a different runtime last registered (for example a mock in unit tests), that read can return another env's block, so FromEnv returned a context whose env did not match and the scope constructor threw, crashing the host. FromEnv now returns a context only when its environment handle matches the requested env.
Fix a regression where IDisposable module instances loaded into one managed host disposed each other: ExportModule inferred T=IDisposable and registered every module (and, on the module-less path, the context itself) under one type-keyed annotation, so loading a second module displaced and disposed the first mid-load. Module instances now register in an append-many list on the context (AddModuleDisposable), each disposed once at teardown; the context is never registered as its own module disposable. Adds a regression test that loads two IDisposable modules through ExportModule. Also: JSValueScope.Dispose fetches the env only for handle/escapable scopes so disposing a runtime scope after its context is torn down does not throw; document the intentional per-env instance-data block retention at its allocation; move the rooting-GCHandle doc onto ContextHandle.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 35 out of 35 changed files in this pull request and generated 4 comments.
Suppressed comments (3)
Previously missed (1) — in code that hasn't changed since the last review.
src/NodeApi/DotNetHost/NativeHost.cs:501
- On .NET Framework this releases
_runtimeHost, but environment teardown later requires that same pointer to callOnEnvironmentFinalize. After an explicit JSdispose(), the condition inNotifyManagedHostEnvironmentFinalizeis false, so the managed registration GCHandle and context are never released. Use the full disposal path so the managed host is notified before the runtime-host pointer is cleared.
// 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.
exports.DefineProperties(new JSPropertyDescriptor(
"dispose", (_) => { CloseRuntimeHost(); return default; }));
src/NodeApi/Interop/JSRuntimeContext.cs:291
- This unconditionally overwrites an occupied slot. A second
JSRuntimeContext.Createfor the same environment leaves the first context rooted by a GCHandle that is no longer reachable through instance data, so it is never disposed. Reject an already-populated slot (and free the newly allocated handle on registration failure) to enforce the stated one-context-per-env invariant.
((nint*)instanceData)[s_instanceDataSlot] = ContextHandle;
src/NodeApi/JSReference.cs:374
- The context can be disposed after the
IsDisposedcheck but before this post.JSTsfnSynchronizationContext.Postitself also checks then callsNonBlockingCall, whileDisposecan concurrently release the TSFN, producing a native use-after-release. A try/catch cannot protect that race; gate in-flight TSFN calls against release before using this path for cross-thread reference cleanup.
if (disposing)
{
// Delete the reference on the JS thread (inline if already there).
_context.SynchronizationContext.Post(
() => runtime.DeleteReference(env, handle).ThrowIfFailed(), allowSync: true);
| if (!_moduleDisposables.Contains(disposable)) | ||
| { | ||
| _moduleDisposables.Add(disposable); | ||
| } |
| // Retained for the env's lifetime, never freed here or by the finalizer: a late | ||
| // wrapped-object finalizer may still read a cleared slot via FromEnv after teardown | ||
| // (see FinalizeInstanceData), so freeing this block would risk a use-after-free. | ||
| instanceData = Marshal.AllocHGlobal(IntPtr.Size * InstanceDataSlotCount); |
| _env = context.UncheckedEnvironmentHandle; | ||
| ThreadId = Environment.CurrentManagedThreadId; | ||
| Runtime = context.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); | ||
| using JSValueScope scope = JSValueScope.CreateRuntimeScope(env, context); |
Type of change
JSValueScopeis now constructed through staticfactory methods, and
JSValueScopeTypeis internal.Why
Node-API values (
napi_value) and references (napi_ref) have strict, environment-scopedlifetimes, but the previous design spread responsibility for those lifetimes across several
overlapping concepts — five
JSValueScopetypes, a separateJSModuleContext, and a"no-context"
JSReferencepath. That made two simple invariants hard to guarantee:napi_valueis valid exactly while itsJSValueScopeis open, andJSReferenceis owned by the oneJSRuntimeContextbound to itsnapi_env.This change makes those invariants structural. It builds on the recent worker-teardown crash
fixes (#487, #492) and removes the need for the separate no-context follow-up (#495) by
eliminating that path entirely.
What
A
napi_valueis valid exactly while itsJSValueScopeis open. Value validity flowsentirely through the owning scope, so using a value after its scope closes fails predictably
instead of depending on scope-type-specific handling.
JSValueis correspondingly simpler.A
JSReferenceis owned by theJSRuntimeContextof itsnapi_env. Reference cleanupis always posted to the owning JS thread through that context, and the finalizer never
touches JS state off-thread, so it stays crash-safe during environment teardown.
Exactly one
JSRuntimeContextpernapi_env, disposed when the env is finalized. Thecontext is stored in and resolved from the env's instance data, and a native/managed host
handshake disposes it deterministically when the environment's instance data is finalized —
without calling back into JavaScript, since the environment is going away.
Removed the "no-context" concept. Every scope and reference is backed by a runtime
context, which removes a class of teardown edge cases (and makes the no-context reference
leak targeted by Fix no-context JSReference leak and TSFN post/release race (follow-ups to #492) #495 moot).
Simplified
JSValueScope. ReplacedJSModuleContextwith a lightweightStrongBox<object?>module holder that nested scopes inherit; reducedJSValueScopeTypetothree internal values (
RuntimeContext,Handle,Escapable); the public surface is nowstatic factories —
CreateRuntimeScope/CreateHandleScope/CreateEscapableScope— plusJSRuntimeContext.Create.Host, embedding, and generator updated to match. The native and managed hosts and the
embedding adapters create or resolve the context explicitly, and the generated module entry
points split into an AOT path that creates the context and a dynamic path that resolves it.
Tests. Rewrote the scope and reference unit tests for the new model and added coverage
for value escaping, context-from-env resolution, the context factory, synchronization-context
install/restore, the module holder, and off-thread disposal. Added a worker-teardown stress
test that repeatedly loads and tears down the host to exercise the per-environment
init/teardown path.
Build hygiene. Bumped
Nullability.Source(2.1.0→2.3.0) to clear adotnet formatwarning on the package's vendored source file.
Testing
Built and packed in Release, then ran the full test suite — managed unit tests plus the AOT,
hosted-CLR, embedding, and worker-teardown-stress cases — on all target frameworks. All green.
Release notes
Should this change be included in the release notes: yes
Object-lifetime safety: a
napi_value's lifetime is governed by itsJSValueScopeand aJSReference'slifetime by the
JSRuntimeContextof itsnapi_env;JSValueScopeis now created via static factorymethods (
CreateRuntimeScope/CreateHandleScope/CreateEscapableScope) andJSValueScopeTypeis internal (breaking, pre-1.0).