@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
JavaScript
/*
* 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