Skip to content

mhofman/tc39-weakrefs-shim

Repository files navigation

TC39 Weakrefs shim

Early shim implementing the TC39 weakrefs proposal, currently in stage 2.

Most JavaScript environments hide the details of the garbage collection process. It is thus not possible to implement the shim on most platforms without special hooks or hacks. This package aims to provide the foundation to add weakrefs support if such workarounds are available on a specific platform.

Pull requests for improvements or new platform support gladly accepted.

Supported platforms

Node

Node has the ability to expose the underlying engine's garbage collection process through Native Addons. This package uses the native bindings of the weak-napi node module to be notified of the finalization of tracked objects.

Please note there is currently an issue with Node 12 causing intermittent SegFaults.

Mozilla JavaScript shell

The SpiderMonkey shell exposes some privileged APIs as globals, including the ability to recover the keys of a WeakMap.

However, the JS shell is not alike any typical JS environment and lacks a run-loop as well as any task scheduling APIs. For this reason, the shim for the JS Shell is more a proof of concept than anything else. However with a custom task scheduler, all tests do pass.

Chrome/V8 (with command-line flag)

V8 7.4, included in Chrome 74 and Node 12.0, implements the weakrefs proposal natively behind a command-line flag (--js-flags="--harmony-weak-refs").

However, V8's current implementation is not compliant with the latest version of the spec proposal. The shim attempts to detect and wrap broken implementations.

In V8 7.4, the following are notable deviations of the native implementation:

  • FinalizationGroup.prototype.unregister does not work at all
  • FinalizationGroup.prototype.cleanupSome ignores its callback parameter and always invokes the callback provided to the constructor
  • Errors thrown inside the cleanup callback are swallowed and never reach the global unhandled error handler. This only affects the automatic cleanup callback invocation when the engine finds new finalized targets.
  • The CleanupIterator can be used outside of the callback's invocation when it shouldn't (including in async functions)
  • FinalizationGroup instances may leak in some circumstances.
  • The unregisterToken is held strongly so anything it references cannot be collected. In particular, it prevents using the target as the token.

V8 7.7 (Chrome 77 and Node 12.11) fixed some issues with FinalizationGroup.prototype.unregister and FinalizationGroup.prototype.cleanupSome. Since the remaining defects are dependent on garbage collection occurring, the shim can no longer detect them and wrap the native implementation automatically.

V8 7.8 changed the integration of the WeakRef feature with the host environment (browser). Chrome 78 did not update accordingly making the feature virtually useless: WeakRef leak their target which will never be collected, and the FinalizationGroup's cleanup callback is never executed automatically. Only FinalizationGroup.prototype.cleanupSome can process collected targets.

Usage

CommonJS and ES Module support

This package supports both ES Module import and CommonJS require.

Since ES Module support in NodeJS is experimental, and the eco-system hasn't fully standardized dual mode packages (especially isomorphic ones), the ES Module usage may require some extra configuration in the tooling. The implementation uses relative imports with .js extension for browser compatibility.
Any PR welcome for improvements and fixes when used with esm, babel, rollup, Node 12 experimental modules, or directly in modern browsers.

The ES module implementation is in the module folder. The CommonJS implementation is in the lib folder.
The package.json has both a "main" and "module" fields describing the corresponding entrypoints.

Entrypoint

The package's main entrypoint exports a shim async function that dynamically loads the implementation appropriate for the detected platform, if any.
The ES Module implementation internally uses dynamic import.

Since an implementation may not be available for the platform, the module entrypoint also exports an available boolean constant.

export const available: boolean;

export async function shim(
    wrapBrokenImplementation: boolean = true
): Promise<{
    WeakRef: WeakRef.Constructor,
    FinalizationGroup: FinalizationGroup.Constructor,
}>;

Example

import * as weakrefs from "tc39-weakrefs-shim";

if (weakrefs.available)
    (async () => {
        const { WeakRef, FinalizationGroup } = await weakrefs.shim();

        let obj = {};

        const weakRef = new WeakRef(obj);

        const finalizationGroup = new FinalizationGroup(iterator =>
            console.log(...iterator)
        );

        finalizationGroup.register(obj, "myObject");

        obj = undefined;
    })();

Wrapper

The shim export, as well as the dynamic import, automatically wraps a broken weakrefs native implementation. To prevent the behavior, use the static import and call shim with wrapBrokenImplementation = false.

Dynamic import

As syntactic sugar, the entrypoint will automatically load the platform's shim when dynamically imported.

(async () => {
    const { WeakRef, FinalizationGroup } = await import("tc39-weakrefs-shim");

    if (!WeakRef || !FinalizationGroup) return;

    let obj = {};

    const weakRef = new WeakRef(obj);

    const finalizationGroup = new FinalizationGroup(iterator =>
        console.log(...iterator)
    );

    finalizationGroup.register(obj, "myObject");

    obj = undefined;
})();

Direct import of a platform's implementation

The dynamic loading can be skipped and the implementation directly imported, e.g. if an external capability check is done, or the target platform is known in advance.

import { WeakRef, FinalizationGroup } from "tc39-weakrefs-shim/module/node";
const { WeakRef, FinalizationGroup } = require("tc39-weakrefs-shim/lib/node");

The wrapper can be imported directly as well.

import * as globalWeakrefs from "tc39-weakrefs-shim/module/global";
import { wrap } from "tc39-weakrefs-shim/module/wrapper";

const { WeakRef, FinalizationGroup } = wrap(
    globalWeakrefs.WeakRef,
    globalWeakrefs.FinalizationGroup
);

Implementation details

Tests

The package includes generic tests for the weakrefs APIs.

On each platform the loaded shim is tested against those.
If the native implementation is detected as broken, the wrapped implementation is used to avoid failing tests.

Some tests rely on the garbage collector being exposed as the gc() global. On V8 (Chrome and node), this is done through the --expose-gc command line flag.

If adding an implementation for another platform, please make sure the tests pass.

Internal API

A new implementation should leverage the internal API to create the WeakRef and FinalizationGroup exports

Note: This section is not up-to-date with the current implementation

Abstract types and operations

  • type ObjectInfo
    An opaque value representing information about a target object. It should not strongly hold the target as the info will be strongly held by the different internal objects.
  • getInfo(target: object): ObjectInfo
    A method to get or create an info value for a specific target.
  • isAlive(info: ObjectInfo): boolean
    A method to check if the target represented by an ObjectInfo value is still alive.
  • getTarget(info: ObjectInfo): object | undefined
    A method to get the target object represented by an ObjectInfo value if still alive, or undefined if not.

Agent

A object abstracting the steps performed by the ECMAScript Agent.

WeakRef objects internally call agent.keepDuringJob() when constructed and on deref().
FinalizationGroup objects internally register with the agent. More specifically they register for each registered target, so that a group can be released when all its registered targets are finalized.

agent.finalization() performs the DoAgentFinalization Job.

The agent's constructor takes 2 parameters:

  • getDeadObjectInfos(): Set<ObjectInfo>
    A function called during the finalization job that should return a set containing ObjectInfo values for dead targets.
  • hooks: callbacks for different stages of the agent's jobs:
    • holdObject(object: object): void
      Used to perform any extra step when an object is held by the agent. Currently this should inform the scheduler that the finalization job needs to run to release the objects.
    • releaseObject(object: object): void
      Used to perform any extra step when an object is released by the agent.
    • registerObjectInfo(info: ObjectInfo): void
      Called the first time a target has been registered with any FinalizationGroup.
    • unregisterObjectInfo(info: ObjectInfo): void
      Called when a target is no longer registered with any FinalizationGroup, due to either finalizationGroup.unregister() calls or after finalization of the target.

AgentFinalizationJobScheduler

The makeAgentFinalizationJobScheduler() export creates a simple task scheduler for an agent's finalization. It's only purpose is to enqueue a task when the agent.finalization() job needs to be performed, and cancel the task if it's no longer needed.

It returns a function, called updater, that is used to inform the scheduler for the need of a finalization job.
The updater function should be called with a true parameter if there are new dead objects that need to go through the finalization step. Currently, the updater function should also be called without any argument when objects might be held by the agent and need to be released. The scheduler will check if the agent is holding objects and schedule a finalization job even if no dead objects have been found.

WeakRef

The createWeakRefClassShim() export creates a WeakRef class for a given agent, using the getInfo and getTarget operations described above.

The function returns a tuple of the WeakRef constructor and a function to access the internal Slots object of a WeakRef instance. The later can be used if an implementation needs to create a subclass of WeakRef with access to its internal slots.

FinalizationGroup

The createFinalizationGroupClassShim() export creates a FinalizationGroup class for a given agent, using the getInfo and isAlive operations described above.

The function returns the FinalizationGroup constructor.

Conformance

The agent.finalization() method is not currently fully conformant with the DoAgentFinalization job in the spec. It does not enqueue a job for each registered finalizationGroup's cleanup job, but instead calls them all synchronously.
To prevent any error in a user cleanupCallback to interrupt the completion of the job, errors are caught, merged and retrown as a single error at the end of the finalization task.

The scheduling of the agent.finalization() job, while left to the specific platform shim's implementation, is likely implemented using a full task (e.g. setImmediate) since there is usually no platform primitive to hook into the microtask checkpoint.
In particular, this means an object held by the agent may stay alive longer than the end of the current job.

About

Shim for TC39 Weakrefs proposal

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published