@lightningjs/threadx
Version:
A web browser-based JavaScript library that helps manage the communcation of data between one or more web worker threads.
361 lines (336 loc) • 12.5 kB
text/typescript
/*
* 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 type { IEventEmitter } from './IEventEmitter.js';
import type { BufferStruct, BufferStructConstructor } from './BufferStruct.js';
import { ThreadX } from './ThreadX.js';
import { assertTruthy } from './utils.js';
export class SharedObject implements IEventEmitter {
/**
* 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.
*/
private threadx: ThreadX;
private sharedObjectStruct: BufferStruct | null;
protected mutations: { [s in string]?: true };
private waitPromise: Promise<void> | null = null;
private mutationsQueued = false;
static staticInitialized = false;
private _id: number;
private _typeId: number;
private initialized = false;
private destroying = false;
declare z$__type__Props: object;
protected curProps: this['z$__type__Props'];
/**
* Extract the buffer from a SharedObject
*
* @remarks
* For internal use by ThreadX only
*
* @param sharedObject
* @returns
*/
static extractBuffer(sharedObject: SharedObject): SharedArrayBuffer {
if (sharedObject.destroying || !sharedObject.sharedObjectStruct) {
throw new Error(
'SharedObject.extractBuffer(): SharedObject is or was being destroyed.',
);
}
return sharedObject.sharedObjectStruct.buffer;
}
constructor(
sharedObjectStruct: BufferStruct,
curProps: Record<string, unknown>,
) {
this.curProps = curProps;
this.threadx = ThreadX.instance;
this.sharedObjectStruct = sharedObjectStruct;
this._id = sharedObjectStruct.id;
this._typeId = sharedObjectStruct.typeId;
const constructor = this.constructor as typeof SharedObject;
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 (this: SharedObject) {
return this.curProps[key as keyof object];
},
set: function (this: SharedObject, value: unknown) {
this.curProps[key as keyof SharedObject['curProps']] =
value as never;
this.mutations[key as keyof object] = true;
this.queueMutations();
},
});
});
}
this.mutations = {};
this._executeMutations();
this.initialized = true;
}
get typeId(): number {
return this._typeId;
}
get id(): number {
return this._id;
}
/**
* Assumes lock is acquired
*/
protected processDirtyProperties(): void {
if (!this.sharedObjectStruct) {
throw new Error('SharedObject was destroyed');
}
const { sharedObjectStruct, mutations, curProps } = this;
(
sharedObjectStruct.constructor as BufferStructConstructor
).propDefs.forEach((propDef, index) => {
if (sharedObjectStruct.isDirty(index)) {
const propName = propDef.name as keyof BufferStruct;
// 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 as keyof SharedObject['curProps']];
// Apply the mutation from the other worker
curProps[propName as keyof SharedObject['curProps']] =
sharedObjectStruct[propName] as never;
// Don't call onPropertyChange during the initialization process
if (this.initialized) {
this.onPropertyChange(
propName as keyof SharedObject['curProps'],
sharedObjectStruct[propName] as never,
oldValue,
);
}
}
});
sharedObjectStruct.resetDirty();
}
onPropertyChange<Key extends keyof this['z$__type__Props']>(
propName: Key,
newValue: this['z$__type__Props'][Key],
oldValue: this['z$__type__Props'][Key] | undefined,
): void {
// console.log(`onPropertyChange: ${propName} = ${value} (${this.dirtyProcessCount}, ${ThreadX.workerName)`);
}
queueMutations(): void {
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);
});
}
private async mutationMicrotask() {
if (!this.sharedObjectStruct) {
throw new Error('SharedObject was destroyed');
}
await this.sharedObjectStruct.lockAsync(async () => {
this._executeMutations();
});
if (this.destroying) {
this.finishDestroy();
}
}
public flush(): void {
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.
*/
protected onDestroy(): void {
// 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.
*/
public destroy(): void {
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();
}
private finishDestroy(): void {
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(): boolean {
return this.sharedObjectStruct === null;
}
private _executeMutations(): void {
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 as keyof SharedObject['curProps']];
// Workaround TypeScript limitation re-assigning to dynamic keys of a class instance:
// https://github.com/microsoft/TypeScript/issues/53738
const oldValue = this.sharedObjectStruct[key as keyof BufferStruct];
// @ts-expect-error Ignore the read-only assignment errors
this.sharedObjectStruct[key as keyof BufferStruct] =
value as unknown as typeof oldValue;
}
}
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
private eventListeners: { [eventName: string]: any } = {};
on(event: string, listener: (target: any, data: any) => void): void {
// 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: string, listener: (target: any, data: any) => void): void {
// 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: string, listener: (target: any, data: any) => void): void {
const onceListener = (target: any, data: any) => {
this.off(event, onceListener);
listener(target, data);
};
this.on(event, onceListener);
}
emit(
event: string,
data: Record<string, unknown>,
options: { localOnly?: boolean } = {},
): void {
// 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);
});
}
//#endregion EventEmitter
}