@lightningjs/threadx
Version:
A web browser-based JavaScript library that helps manage the communcation of data between one or more web worker threads.
539 lines (497 loc) • 16.3 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 { ThreadX } from './ThreadX.js';
import { stringifyTypeId } from './buffer-struct-utils.js';
const TYPEID_INT32_INDEX = 0;
const NOTIFY_INT32_INDEX = 1;
const LOCK_INT32_INDEX = 2;
const DIRTY_INT32_INDEX = 6;
const UNDEFINED_INT32_INDEX = 8;
const ID_FLOAT64_INDEX = 2;
const MAX_STRING_SIZE = 255;
export type StructPropType = 'string' | 'number' | 'boolean' | 'int32';
export type BufferStructConstructor<
WritableProps = object,
T extends BufferStruct = BufferStruct,
> = {
new (): T & WritableProps;
propDefs: PropDef[];
};
function valueIsType(
expectedType: 'number',
type: string,
value: unknown,
): value is number;
function valueIsType(
expectedType: 'int32',
type: string,
value: unknown,
): value is number;
function valueIsType(
expectedType: 'boolean',
type: string,
value: unknown,
): value is boolean;
function valueIsType(
expectedType: 'string',
type: string,
value: unknown,
): value is string;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function valueIsType(
expectedType: string,
type: string,
value: unknown,
): boolean {
return expectedType === type;
}
function valuesAreEqual(a: number, b: unknown): b is number;
function valuesAreEqual(a: boolean, b: unknown): b is boolean;
function valuesAreEqual(a: string, b: unknown): b is string;
function valuesAreEqual(a: string | number | boolean, b: unknown): boolean {
return a === b;
}
export interface StructPropOptions {
propToBuffer?(value: unknown): unknown;
bufferToProp?(value: unknown): unknown;
/**
* Allow the value of this property to be undefined.
*
* @remarks
* If true, the property will be undefined by default. Be sure to type
* the property as `type | undefined` in the BufferStruct interface,
* getter and setter.
*/
allowUndefined?: boolean;
}
export function structProp(type: StructPropType, options?: StructPropOptions) {
return function (
target: BufferStruct,
key: string,
descriptor: PropertyDescriptor,
): void {
const constructor = target.constructor as typeof BufferStruct;
// Make sure the static initializer has been called. We must check that the
// constructor directly has its "own property" because it may be inherited
// from a parent class.
if (
!Object.prototype.hasOwnProperty.call(constructor, 'staticInitialized') ||
!constructor.staticInitialized
) {
constructor.initStatic();
}
let byteOffset = constructor.size;
let offset = 0;
let byteSize = 0;
if (type === 'string') {
byteOffset += byteOffset % 2;
offset = byteOffset / 2;
byteSize = (MAX_STRING_SIZE + 1) * 2; // 16-bits for size then 255 16-bit characters
} else if (type === 'int32' || type === 'boolean') {
byteOffset += byteOffset % 4;
offset = byteOffset / 4;
byteSize = 4;
} else if (type === 'number') {
byteOffset += byteOffset % 8;
offset = byteOffset / 8;
byteSize = 8;
}
const propDefs = constructor.propDefs;
const propNum = propDefs.length;
const allowUndefined = !!options?.allowUndefined;
const propDef: PropDef = {
propNum,
name: key,
type,
byteOffset,
offset,
byteSize,
allowUndefined,
};
propDefs.push(propDef);
// console.log(constructor.size, byteOffset, byteSize, propDef);
constructor.size = byteOffset + byteSize;
// TODO: Move the descriptors to the prototype to avoid code duplication/closures
descriptor.get = function (this: BufferStruct) {
let value: unknown;
if (allowUndefined && this.isUndefined(propNum)) {
value = undefined;
} else if (type === 'string') {
const length = this.uint16array[offset];
if (!length) return '';
if (length > MAX_STRING_SIZE) {
// This should never happen because we truncate the string when setting it
throw new Error(
`get SharedObject.${key}: Text length is too long. Length: ${length}`,
);
}
value = String.fromCharCode(
...this.uint16array.slice(offset + 1, offset + 1 + length),
);
} else if (type === 'int32') {
value = this.int32array[offset];
} else if (type === 'boolean') {
value = !!this.int32array[offset];
} else if (type === 'number') {
value = this.float64array[offset];
}
if (options?.bufferToProp) {
value = options.bufferToProp(value);
}
return value;
};
descriptor.set = function (this: BufferStruct, value: unknown) {
if (options?.propToBuffer) {
value = options.propToBuffer(value);
}
if (allowUndefined) {
const isUndefined = this.isUndefined(propNum);
if (value === undefined) {
if (isUndefined) return;
this.setDirty(propNum);
this.setUndefined(propNum, true);
return;
} else if (isUndefined) {
this.setDirty(propNum);
this.setUndefined(propNum, false);
}
}
if (valueIsType('string', type, value)) {
if (!valuesAreEqual(value, this[key as keyof BufferStruct])) {
this.setDirty(propNum);
// Copy string into shared memory in the most efficient way possible
let length = value.length;
if (length > MAX_STRING_SIZE) {
console.error(
`set SharedObject.${key}: Text length is too long. Truncating...`,
length,
);
length = MAX_STRING_SIZE;
}
this.uint16array[offset] = length;
const startOffset = offset + 1;
const endOffset = startOffset + length;
let charIndex = 0;
for (let i = startOffset; i < endOffset; i++) {
this.uint16array[i] = value.charCodeAt(charIndex++);
}
}
} else if (valueIsType('int32', type, value)) {
if (!valuesAreEqual(value, this[key as keyof BufferStruct])) {
this.setDirty(propNum);
this.int32array[offset] = value;
}
} else if (valueIsType('boolean', type, value)) {
if (!valuesAreEqual(value, this[key as keyof BufferStruct])) {
this.setDirty(propNum);
this.int32array[offset] = value ? 1 : 0;
}
} else if (valueIsType('number', type, value)) {
if (!valuesAreEqual(value, this[key as keyof BufferStruct])) {
this.setDirty(propNum);
this.float64array[offset] = value;
}
}
};
};
}
interface PropDef {
propNum: number;
name: string;
type: StructPropType;
byteOffset: number;
offset: number;
byteSize: number;
allowUndefined: boolean;
}
/**
* BufferStruct Header Structure:
* Int32[0]
* Type ID: Type of object (32-bit identifier)
* Int32[1]
* Notify / Last Mutator Worker ID
* Int32[2]
* Lock
* Int32[3]
* RESERVED (64-bit align)
* Int32[4 - 5] / Float64[ID_FLOAT64_INDEX = 2]
* Shared Unique ID of the object
* Int32[DIRTY_INT32_INDEX = 6]
* Dirty Bit Mask 1 (Property Indices 0-31)
* Int32[DIRTY_INT32_INDEX + 1 = 7]
* Dirty Bit Mask 2 (Property Indices 32-63)
* Int32[UNDEFINED_INT32_INDEX = 8]
* Undefined Bit Mask 1 (Property Indices 0-31)
* Int32[UNDEFINED_INT32_INDEX + 1 = 9]
* Undefined Bit Mask 2 (Property Indices 32-63)
*
* HEADER SIZE MUST BE A MULTIPLE OF 8 BYTES (64-BIT ALIGNMENT)
*/
export abstract class BufferStruct {
buffer: SharedArrayBuffer;
// Lock ID that is a valid 32-bit random integer
protected lockId = Math.floor(Math.random() * 0xffffffff);
protected uint16array: Uint16Array;
protected int32array: Int32Array;
protected float64array: Float64Array;
static staticInitialized = false;
static typeId = 0;
static typeIdStr = '';
static size = 10 * 4; // Header size
static propDefs: PropDef[] = [];
constructor(buffer?: SharedArrayBuffer) {
const constructor = this.constructor as typeof BufferStruct;
// Make sure the static initializer has been called. We must check that the
// constructor directly has its "own property" because it may be inherited
// from a parent class.
if (
!Object.prototype.hasOwnProperty.call(constructor, 'staticInitialized') ||
!constructor.staticInitialized
) {
constructor.initStatic();
}
const isNew = !buffer;
if (!buffer) {
// Round constructor.size to the nearest multiple of 8 bytes (64-bit alignment)
buffer = new SharedArrayBuffer(Math.ceil(constructor.size / 8) * 8);
}
this.buffer = buffer;
this.uint16array = new Uint16Array(buffer);
this.int32array = new Int32Array(buffer);
this.float64array = new Float64Array(buffer);
const typeId = constructor.typeId;
// If this is a new buffer, initialize the TypeID and ID
if (isNew) {
this.int32array[TYPEID_INT32_INDEX] = typeId;
this.float64array[ID_FLOAT64_INDEX] = ThreadX.instance.generateUniqueId();
// Iterate the propDefs and set undefined for all properties marked `allowUndefined`
for (const propDef of constructor.propDefs) {
if (propDef.allowUndefined) {
this.setUndefined(propDef.propNum, true);
}
}
} else if (this.int32array[TYPEID_INT32_INDEX] !== typeId) {
// If this is an existing buffer, verify the TypeID is the same as expected
// by this class
throw new Error(
`BufferStruct: TypeId mismatch. Expected '${
constructor.typeIdStr
}', got '${stringifyTypeId(this.int32array[TYPEID_INT32_INDEX]!)}'`,
);
}
}
/**
* Safely extract the TypeID from any SharedArrayBuffer (as if it is a BufferStruct)
*
* @remarks
* Does not check if the TypeID is valid however it does a basic sanity check to
* ensure the buffer is large enough to contain the TypeID at Int32[TYPEID_INT32_INDEX].
*
* If the buffer is found to be invalid, 0 is returned.
*
* @param buffer
* @returns
*/
static extractTypeId(buffer: SharedArrayBuffer): number {
if (buffer.byteLength < BufferStruct.size || buffer.byteLength % 8 !== 0) {
return 0;
}
return new Int32Array(buffer)[TYPEID_INT32_INDEX] || 0;
}
/**
* Checks if typeId is valid and sets up static properties when the first
* structProp() decorator is set-up on the class.
*
* @remarks
* WARNING: This should not ever be called directly.
*
* @internal
*/
static initStatic() {
const typeIdStr = stringifyTypeId(this.typeId);
if (typeIdStr === '????') {
throw new Error(
'BufferStruct.typeId must be set to a valid 32-bit integer',
);
}
this.typeIdStr = typeIdStr;
this.propDefs = [...this.propDefs];
this.staticInitialized = true;
}
protected setDirty(propIndex: number) {
const dirtyWordOffset = Math.floor(propIndex / 32);
const dirtyBitOffset = propIndex - dirtyWordOffset * 32;
this.int32array[DIRTY_INT32_INDEX + dirtyWordOffset] =
this.int32array[DIRTY_INT32_INDEX + dirtyWordOffset]! |
(1 << dirtyBitOffset);
}
resetDirty() {
// TODO: Do we need to use atomics here?
this.int32array[NOTIFY_INT32_INDEX] = 0;
this.int32array[DIRTY_INT32_INDEX] = 0;
this.int32array[DIRTY_INT32_INDEX + 1] = 0;
}
isDirty(propIndex?: number): boolean {
if (propIndex !== undefined) {
const dirtyWordOffset = Math.floor(propIndex / 32);
const dirtyBitOffset = propIndex - dirtyWordOffset * 32;
return !!(
this.int32array[DIRTY_INT32_INDEX + dirtyWordOffset]! &
(1 << dirtyBitOffset)
);
}
return !!(
this.int32array[DIRTY_INT32_INDEX] ||
this.int32array[DIRTY_INT32_INDEX + 1]
);
}
protected setUndefined(propIndex: number, value: boolean) {
const undefWordOffset = Math.floor(propIndex / 32);
const undefBitOffset = propIndex - undefWordOffset * 32;
if (value) {
this.int32array[UNDEFINED_INT32_INDEX + undefWordOffset] =
this.int32array[UNDEFINED_INT32_INDEX + undefWordOffset]! |
(1 << undefBitOffset);
} else {
this.int32array[UNDEFINED_INT32_INDEX + undefWordOffset] =
this.int32array[UNDEFINED_INT32_INDEX + undefWordOffset]! &
~(1 << undefBitOffset);
}
}
protected isUndefined(propIndex: number): boolean {
const undefWordOffset = Math.floor(propIndex / 32);
const undefBitOffset = propIndex - undefWordOffset * 32;
return !!(
this.int32array[UNDEFINED_INT32_INDEX + undefWordOffset]! &
(1 << undefBitOffset)
);
}
get typeId(): number {
// Atomic load not required here because typeId is constant
return this.int32array[TYPEID_INT32_INDEX]!;
}
get id(): number {
// Atomic load not required here because id is constant
return this.float64array[ID_FLOAT64_INDEX]!;
}
/**
* Returns the current notify value
*/
get notifyValue(): number {
return Atomics.load(this.int32array, NOTIFY_INT32_INDEX);
}
/**
* Returns true if the BufferStruct is currently locked
*/
get isLocked(): boolean {
return Atomics.load(this.int32array, LOCK_INT32_INDEX) !== 0;
}
lock<T>(callback: () => T): T {
let origLock = Atomics.compareExchange(
this.int32array,
LOCK_INT32_INDEX,
0,
this.lockId,
);
while (origLock !== 0) {
try {
Atomics.wait(this.int32array, LOCK_INT32_INDEX, origLock);
} catch (e: unknown) {
if (
e instanceof TypeError &&
e.message === 'Atomics.wait cannot be called in this context'
) {
// Atomics.wait() not supported in this context (main worker), so just spin
// TODO: Maybe we detect this earlier and avoid this exception? This works for now.
} else {
throw e;
}
}
origLock = Atomics.compareExchange(
this.int32array,
LOCK_INT32_INDEX,
0,
this.lockId,
);
}
let result: T;
try {
result = callback();
} finally {
Atomics.store(this.int32array, LOCK_INT32_INDEX, 0);
Atomics.notify(this.int32array, LOCK_INT32_INDEX);
}
return result;
}
async lockAsync<T>(callback: (...args: any[]) => Promise<T>): Promise<T> {
let origLock = Atomics.compareExchange(
this.int32array,
LOCK_INT32_INDEX,
0,
this.lockId,
);
while (origLock !== 0) {
const result = Atomics.waitAsync(
this.int32array,
LOCK_INT32_INDEX,
origLock,
);
await result.value;
origLock = Atomics.compareExchange(
this.int32array,
LOCK_INT32_INDEX,
0,
this.lockId,
);
}
let result: T;
try {
result = await callback();
} finally {
Atomics.store(this.int32array, LOCK_INT32_INDEX, 0);
Atomics.notify(this.int32array, LOCK_INT32_INDEX);
}
return result;
}
notify(value?: number) {
if (value !== undefined) {
Atomics.store(this.int32array, NOTIFY_INT32_INDEX, value);
}
return Atomics.notify(this.int32array, NOTIFY_INT32_INDEX);
}
wait(expectedValue: number, timeout = Infinity) {
const result = Atomics.wait(
this.int32array,
NOTIFY_INT32_INDEX,
expectedValue,
timeout,
);
return result;
}
async waitAsync(
expectedValue: number,
timeout = Infinity,
): Promise<'not-equal' | 'timed-out' | 'ok'> {
const result = Atomics.waitAsync(
this.int32array,
NOTIFY_INT32_INDEX,
expectedValue,
timeout,
);
return result.value;
}
}