UNPKG

@lightningjs/threadx

Version:

A web browser-based JavaScript library that helps manage the communcation of data between one or more web worker threads.

311 lines 12.1 kB
/* * Copyright 2023 Comcast Cable Communications Management, LLC * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * * SPDX-License-Identifier: Apache-2.0 */ import { ThreadX } from './ThreadX.js'; import { assertTruthy } from './utils.js'; class SharedObject { /** * The ThreadX instance that this SharedObject should interact with * * @remarks * It's unsafe to use `ThreadX.instance` in different, especially asyncronous, * locations directly because it may change during the lifetime of a * SharedObject. At least it can during tests. So this one should always * be referenced when needed. */ threadx; sharedObjectStruct; mutations; waitPromise = null; mutationsQueued = false; static staticInitialized = false; _id; _typeId; initialized = false; destroying = false; curProps; /** * Extract the buffer from a SharedObject * * @remarks * For internal use by ThreadX only * * @param sharedObject * @returns */ static extractBuffer(sharedObject) { if (sharedObject.destroying || !sharedObject.sharedObjectStruct) { throw new Error('SharedObject.extractBuffer(): SharedObject is or was being destroyed.'); } return sharedObject.sharedObjectStruct.buffer; } constructor(sharedObjectStruct, curProps) { this.curProps = curProps; this.threadx = ThreadX.instance; this.sharedObjectStruct = sharedObjectStruct; this._id = sharedObjectStruct.id; this._typeId = sharedObjectStruct.typeId; const constructor = this.constructor; if (!Object.prototype.hasOwnProperty.call(constructor, 'staticInitialized') || !constructor.staticInitialized) { constructor.staticInitialized = true; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const prototype = Object.getPrototypeOf(this); Object.keys(curProps).forEach((key) => { Object.defineProperty(prototype, key, { get: function () { return this.curProps[key]; }, set: function (value) { this.curProps[key] = value; this.mutations[key] = true; this.queueMutations(); }, }); }); } this.mutations = {}; this._executeMutations(); this.initialized = true; } get typeId() { return this._typeId; } get id() { return this._id; } /** * Assumes lock is acquired */ processDirtyProperties() { if (!this.sharedObjectStruct) { throw new Error('SharedObject was destroyed'); } const { sharedObjectStruct, mutations, curProps } = this; sharedObjectStruct.constructor.propDefs.forEach((propDef, index) => { if (sharedObjectStruct.isDirty(index)) { const propName = propDef.name; // If this property has a pending mutation from this worker, then // cancel it. The mutation from the other worker that has already // been applied to the SharedArrayBuffer will take precedence. delete mutations[propName]; const oldValue = curProps[propName]; // Apply the mutation from the other worker curProps[propName] = sharedObjectStruct[propName]; // Don't call onPropertyChange during the initialization process if (this.initialized) { this.onPropertyChange(propName, sharedObjectStruct[propName], oldValue); } } }); sharedObjectStruct.resetDirty(); } onPropertyChange(propName, newValue, oldValue) { // console.log(`onPropertyChange: ${propName} = ${value} (${this.dirtyProcessCount}, ${ThreadX.workerName)`); } queueMutations() { if (this.mutationsQueued) { return; } this.mutationsQueued = true; queueMicrotask(() => { this.mutationsQueued = false; // If the SharedObject has been destroyed, then forget about processing // any mutations. if (!this.sharedObjectStruct) { return; } this.mutationMicrotask().catch(console.error); }); } async mutationMicrotask() { if (!this.sharedObjectStruct) { throw new Error('SharedObject was destroyed'); } await this.sharedObjectStruct.lockAsync(async () => { this._executeMutations(); }); if (this.destroying) { this.finishDestroy(); } } flush() { if (this.destroying || !this.sharedObjectStruct) { throw new Error('SharedObject was destroyed'); } this.sharedObjectStruct.lock(() => { this._executeMutations(); }); } /** * Called when the SharedObject is being destroyed. * * @remarks * This is an opportunity to clean up anything just prior to the SharedObject * being completely destroyed. Shared mutations are allowed in this method. * * IMPORTANT: * `super.onDestroy()` must be called at the END of any subclass override to * ensure proper cleanup. */ onDestroy() { // Implement in subclass } /** * Destroy the SharedObject on this worker only. * * @remarks * This stops any internal mutation processing, releases the reference * to the underlying BufferStruct/SharedArrayBuffer, and removes all * event listeners so that the SharedObject can be garbage collected. * * This does not destroy the SharedObject on other worker. To do that, * call `SharedObject.destroy()` on the other worker. */ destroy() { const struct = this.sharedObjectStruct; if (this.destroying || !struct) { return; } this.emit('beforeDestroy', {}, { localOnly: true }); this.destroying = true; this.onDestroy(); // The remainter of the destroy process (this.finishDestroy) is called // after the next set of mutations is processed. This is to ensure that // any final mutations that are queued up are sent to the opposite thread // before the SharedObject is destroyed on this worker. this.queueMutations(); } finishDestroy() { const struct = this.sharedObjectStruct; if (!this.destroying || !struct) { return; } // Remove this object from ThreadX // Silently because ThreadX may already have been removed if this object // is being destroyed because the current worker was told to forget about it. this.threadx.forgetObjects([this], { silent: true }).catch(console.error); // Release the reference to the underlying BufferStruct/SharedArrayBuffer this.sharedObjectStruct = null; // Submit a notify in order to wake up self or other worker if waiting // on the struct. Need to do this otherwise memory leaks. struct.notify(); // Emit the afterDestroy event this.emit('afterDestroy', {}, { localOnly: true }); // Remove all event listeners this.eventListeners = {}; } get isDestroyed() { return this.sharedObjectStruct === null; } _executeMutations() { if (!this.sharedObjectStruct) { // SharedObject was destroyed so there's nothing to do return; } // Only process properties if the SharedObject is dirty and the current // worker is not the one that last modified it. if (this.sharedObjectStruct.notifyValue !== this.threadx.workerId && this.sharedObjectStruct.isDirty()) { this.processDirtyProperties(); } const { mutations } = this; this.mutations = {}; for (const key in mutations) { if (Object.prototype.hasOwnProperty.call(mutations, key)) { const value = this.curProps[key]; // Workaround TypeScript limitation re-assigning to dynamic keys of a class instance: // https://github.com/microsoft/TypeScript/issues/53738 const oldValue = this.sharedObjectStruct[key]; // @ts-expect-error Ignore the read-only assignment errors this.sharedObjectStruct[key] = value; } } if (this.waitPromise) { this.waitPromise = null; } let expectedNotifyValue = this.sharedObjectStruct.notifyValue; if (this.sharedObjectStruct.isDirty()) { this.sharedObjectStruct.notify(this.threadx.workerId); expectedNotifyValue = this.threadx.workerId; } const waitPromise = this.sharedObjectStruct .waitAsync(expectedNotifyValue) .then(async (result) => { // Only respond if this is the most recent wait promise if (this.waitPromise === waitPromise && this.sharedObjectStruct) { assertTruthy(result === 'ok'); this.waitPromise = null; await this.mutationMicrotask(); } }); this.waitPromise = waitPromise; } //#region EventEmitter eventListeners = {}; on(event, listener) { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment let listeners = this.eventListeners[event]; if (!listeners) { listeners = []; } // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call listeners.push(listener); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment this.eventListeners[event] = listeners; } off(event, listener) { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const listeners = this.eventListeners[event]; if (!listeners) { return; } // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call const index = listeners.indexOf(listener); if (index >= 0) { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call listeners.splice(index, 1); } } once(event, listener) { const onceListener = (target, data) => { this.off(event, onceListener); listener(target, data); }; this.on(event, onceListener); } emit(event, data, options = {}) { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const listeners = this.eventListeners[event]; if (!options.localOnly) { // Emit on opposite worker (if shared) ThreadX.instance.__sharedObjectEmit(this, event, data); } if (!listeners) { return; } // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment [...listeners].forEach((listener) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-call listener(this, data); }); } } export { SharedObject }; //# sourceMappingURL=SharedObject.js.map