From 2e58ca9cf17cb53021aa723ab5aea0ecdb1f403f Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Thu, 9 Apr 2026 16:33:52 -0400 Subject: [PATCH] feat: bytes(n) fixed-size byte buffer Co-Authored-By: Claude Sonnet 4.6 --- src/index.ts | 2 ++ src/shared/bytes.ts | 32 +++++++++++++++++++++ src/shared/descriptors.ts | 18 ++++++++++++ src/shared/index.ts | 4 ++- src/shared/reconstruct.ts | 9 ++++-- src/shared/shared.ts | 48 +++++++++++++++++++++++++------ test/shared/bytes.test.ts | 59 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 161 insertions(+), 11 deletions(-) create mode 100644 src/shared/bytes.ts create mode 100644 test/shared/bytes.test.ts diff --git a/src/index.ts b/src/index.ts index ccb6ffa..24e6bc2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ export { workerPool } from './worker-pool.ts'; export { transfer } from './transfer.ts'; export type { Runner } from './runner.ts'; export type { Loadable } from './shared/index.ts'; +export type { Bytes } from './shared/index.ts'; export { Int8, Uint8, @@ -36,4 +37,5 @@ export { int8, uint8, int16, uint16, int32, uint32, int64, uint64, bool, int8atomic, uint8atomic, int16atomic, uint16atomic, int32atomic, uint32atomic, int64atomic, uint64atomic, boolatomic, mutex, rwlock, + bytes, } from './shared/index.ts'; diff --git a/src/shared/bytes.ts b/src/shared/bytes.ts new file mode 100644 index 0000000..be8ffb2 --- /dev/null +++ b/src/shared/bytes.ts @@ -0,0 +1,32 @@ +import type { Loadable } from './loadable.ts'; + +const SHARED = Symbol.for('moroutine.shared'); + +export class Bytes implements Loadable> { + readonly size: number; + readonly view: Uint8Array; + + static byteAlignment = 1; + + constructor(size: number, buffer?: SharedArrayBuffer, byteOffset?: number) { + this.size = size; + const buf = buffer ?? new SharedArrayBuffer(size); + const offset = byteOffset ?? 0; + this.view = new Uint8Array(buf, offset, size); + } + + load(): Readonly { + return this.view as Readonly; + } + + store(value: Uint8Array): void { + if (value.length !== this.size) { + throw new RangeError(`Expected Uint8Array of length ${this.size}, got ${value.length}`); + } + this.view.set(value); + } + + [SHARED](): { tag: string; buffer: SharedArrayBuffer; byteOffset: number; size: number } { + return { tag: 'Bytes', buffer: this.view.buffer as SharedArrayBuffer, byteOffset: this.view.byteOffset, size: this.size }; + } +} diff --git a/src/shared/descriptors.ts b/src/shared/descriptors.ts index ed5a831..5530dc8 100644 --- a/src/shared/descriptors.ts +++ b/src/shared/descriptors.ts @@ -1,3 +1,4 @@ +import { Bytes } from './bytes.ts'; import { Int8 } from './int8.ts'; import { Uint8 } from './uint8.ts'; import { Int16 } from './int16.ts'; @@ -57,3 +58,20 @@ export const boolatomic: Descriptor = makeDescriptor(() => new Atomi export const mutex: Descriptor = makeDescriptor(() => new Mutex(), Mutex.byteSize, Mutex.byteAlignment, Mutex); export const rwlock: Descriptor = makeDescriptor(() => new RwLock(), RwLock.byteSize, RwLock.byteAlignment, RwLock); + +export interface BytesDescriptor extends Bytes { + byteSize: number; + byteAlignment: number; + _class: typeof Bytes; + _size: number; +} + +export function bytes(size: number): BytesDescriptor { + const instance = new Bytes(size); + return Object.assign(instance, { + byteSize: size, + byteAlignment: Bytes.byteAlignment, + _class: Bytes, + _size: size, + }) as BytesDescriptor; +} diff --git a/src/shared/index.ts b/src/shared/index.ts index 68348b1..cca9cab 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -1,4 +1,5 @@ export type { Loadable } from './loadable.ts'; +export type { Bytes } from './bytes.ts'; export { Int8 } from './int8.ts'; export { Uint8 } from './uint8.ts'; export { Int16 } from './int16.ts'; @@ -27,5 +28,6 @@ export { int8, uint8, int16, uint16, int32, uint32, int64, uint64, bool, int8atomic, uint8atomic, int16atomic, uint16atomic, int32atomic, uint32atomic, int64atomic, uint64atomic, boolatomic, mutex, rwlock, + bytes, } from './descriptors.ts'; -export type { Descriptor } from './descriptors.ts'; +export type { Descriptor, BytesDescriptor } from './descriptors.ts'; diff --git a/src/shared/reconstruct.ts b/src/shared/reconstruct.ts index 9dd6f53..9a7ee8c 100644 --- a/src/shared/reconstruct.ts +++ b/src/shared/reconstruct.ts @@ -1,4 +1,5 @@ import { SharedStruct } from './shared-struct.ts'; +import { Bytes } from './bytes.ts'; const SHARED = Symbol.for('moroutine.shared'); @@ -18,7 +19,7 @@ export function serializeArg(arg: unknown): unknown { } return { __shared__: 'SharedStruct', fields: serializedFields }; } - return { __shared__: data.tag, buffer: data.buffer, byteOffset: data.byteOffset }; + return { __shared__: data.tag, buffer: data.buffer, byteOffset: data.byteOffset, ...(data.size !== undefined && { size: data.size }) }; } return arg; } @@ -31,12 +32,16 @@ function serializeStructField(data: { tag: string; [key: string]: unknown }): un } return { __shared__: 'SharedStruct', fields: serializedFields }; } - return { __shared__: data.tag, buffer: data.buffer, byteOffset: data.byteOffset }; + return { __shared__: data.tag, buffer: data.buffer, byteOffset: data.byteOffset, ...(data.size !== undefined && { size: data.size }) }; } export function deserializeArg(arg: unknown): unknown { if (typeof arg === 'object' && arg !== null && '__shared__' in arg) { const data = arg as { __shared__: string; [key: string]: unknown }; + if (data.__shared__ === 'Bytes') { + const typedData = data as { __shared__: string; buffer: SharedArrayBuffer; byteOffset: number; size: number }; + return new Bytes(typedData.size, typedData.buffer, typedData.byteOffset); + } if (data.__shared__ === 'SharedStruct') { const fields = data.fields as Record; const reconstructed: Record = {}; diff --git a/src/shared/shared.ts b/src/shared/shared.ts index 26a89f2..dec631d 100644 --- a/src/shared/shared.ts +++ b/src/shared/shared.ts @@ -1,8 +1,9 @@ -import type { Descriptor } from './descriptors.ts'; +import type { Descriptor, BytesDescriptor } from './descriptors.ts'; import { int32, int64, bool } from './descriptors.ts'; import { Int32 } from './int32.ts'; import { Int64 } from './int64.ts'; import { Bool } from './bool.ts'; +import { Bytes } from './bytes.ts'; import { SharedStruct } from './shared-struct.ts'; import { Tuple } from './tuple.ts'; @@ -10,6 +11,10 @@ function isDescriptor(value: unknown): value is Descriptor { return typeof value === 'function' && 'byteSize' in value && '_class' in value; } +function isBytesDescriptor(value: unknown): value is BytesDescriptor { + return typeof value === 'object' && value !== null && '_size' in value && '_class' in value; +} + function isStructSchema(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -40,6 +45,7 @@ interface LeafEntry { descriptor: Descriptor; offset: number; initialValue?: unknown; + bytesSize?: number; } interface ArrayLeafEntry { @@ -47,6 +53,7 @@ interface ArrayLeafEntry { descriptor: Descriptor; offset: number; initialValue?: unknown; + bytesSize?: number; } interface ArrayStructEntry { @@ -65,7 +72,11 @@ type ArrayEntry = ArrayLeafEntry | ArrayStructEntry | ArrayTupleEntry; function collectLeaves(schema: Record, path: string[], leaves: LeafEntry[], cursor: { offset: number }): void { for (const key in schema) { const value = schema[key]; - if (isDescriptor(value)) { + if (isBytesDescriptor(value)) { + cursor.offset = align(cursor.offset, value.byteAlignment); + leaves.push({ path: [...path, key], descriptor: value as unknown as Descriptor, offset: cursor.offset, bytesSize: value._size }); + cursor.offset += value.byteSize; + } else if (isDescriptor(value)) { cursor.offset = align(cursor.offset, value.byteAlignment); leaves.push({ path: [...path, key], descriptor: value, offset: cursor.offset }); cursor.offset += value.byteSize; @@ -87,7 +98,11 @@ function collectLeaves(schema: Record, path: string[], leaves: function collectArrayLeaves(schema: unknown[], leaves: LeafEntry[], cursor: { offset: number }): void { for (const element of schema) { - if (isDescriptor(element)) { + if (isBytesDescriptor(element)) { + cursor.offset = align(cursor.offset, element.byteAlignment); + leaves.push({ path: [], descriptor: element as unknown as Descriptor, offset: cursor.offset, bytesSize: element._size }); + cursor.offset += element.byteSize; + } else if (isDescriptor(element)) { cursor.offset = align(cursor.offset, element.byteAlignment); leaves.push({ path: [], descriptor: element, offset: cursor.offset }); cursor.offset += element.byteSize; @@ -110,7 +125,11 @@ function collectArrayLeaves(schema: unknown[], leaves: LeafEntry[], cursor: { of function processArraySchema(schema: unknown[], cursor: { offset: number }): { entries: ArrayEntry[] } { const entries: ArrayEntry[] = []; for (const element of schema) { - if (isDescriptor(element)) { + if (isBytesDescriptor(element)) { + cursor.offset = align(cursor.offset, element.byteAlignment); + entries.push({ type: 'leaf', descriptor: element as unknown as Descriptor, offset: cursor.offset, bytesSize: element._size }); + cursor.offset += element.byteSize; + } else if (isDescriptor(element)) { cursor.offset = align(cursor.offset, element.byteAlignment); entries.push({ type: 'leaf', descriptor: element, offset: cursor.offset }); cursor.offset += element.byteSize; @@ -137,6 +156,9 @@ function processArraySchema(schema: unknown[], cursor: { offset: number }): { en function buildTupleFromEntries(entries: ArrayEntry[], buffer: SharedArrayBuffer): Tuple { const elements = entries.map((entry) => { if (entry.type === 'leaf') { + if (entry.bytesSize !== undefined) { + return new Bytes(entry.bytesSize, buffer, entry.offset); + } const instance = new entry.descriptor._class(buffer, entry.offset); if ('initialValue' in entry) { (instance as any).store(entry.initialValue); @@ -158,7 +180,10 @@ function buildStructTree(schema: Record, leaves: LeafEntry[], b const fields: Record = {}; for (const key in schema) { const value = schema[key]; - if (isDescriptor(value)) { + if (isBytesDescriptor(value)) { + const leaf = leaves[leafIndex.i++]; + fields[key] = new Bytes(leaf.bytesSize!, buffer, leaf.offset); + } else if (isDescriptor(value)) { const leaf = leaves[leafIndex.i++]; fields[key] = new leaf.descriptor._class(buffer, leaf.offset); } else if (Array.isArray(value)) { @@ -181,7 +206,10 @@ function buildStructTree(schema: Record, leaves: LeafEntry[], b function processArraySchemaFromLeaves(schema: unknown[], leaves: LeafEntry[], leafIndex: { i: number }): ArrayEntry[] { const entries: ArrayEntry[] = []; for (const element of schema) { - if (isDescriptor(element)) { + if (isBytesDescriptor(element)) { + const leaf = leaves[leafIndex.i++]; + entries.push({ type: 'leaf', descriptor: leaf.descriptor, offset: leaf.offset, bytesSize: leaf.bytesSize }); + } else if (isDescriptor(element)) { const leaf = leaves[leafIndex.i++]; entries.push({ type: 'leaf', descriptor: leaf.descriptor, offset: leaf.offset }); } else if (Array.isArray(element)) { @@ -209,7 +237,7 @@ function processArraySchemaFromLeaves(schema: unknown[], leaves: LeafEntry[], le function countStructLeaves(schema: Record, leaves: LeafEntry[], leafIndex: { i: number }): void { for (const key in schema) { const value = schema[key]; - if (isDescriptor(value)) { + if (isBytesDescriptor(value) || isDescriptor(value)) { leafIndex.i++; } else if (Array.isArray(value)) { countArrayLeaves(value, leaves, leafIndex); @@ -223,7 +251,7 @@ function countStructLeaves(schema: Record, leaves: LeafEntry[], function countArrayLeaves(schema: unknown[], leaves: LeafEntry[], leafIndex: { i: number }): void { for (const element of schema) { - if (isDescriptor(element)) { + if (isBytesDescriptor(element) || isDescriptor(element)) { leafIndex.i++; } else if (Array.isArray(element)) { countArrayLeaves(element, leaves, leafIndex); @@ -257,6 +285,10 @@ export function shared(schema: unknown): any { return instance; } + if (isBytesDescriptor(schema)) { + return schema; // already a standalone instance + } + if (isDescriptor(schema)) { return schema(); } diff --git a/test/shared/bytes.test.ts b/test/shared/bytes.test.ts new file mode 100644 index 0000000..0363dff --- /dev/null +++ b/test/shared/bytes.test.ts @@ -0,0 +1,59 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { shared, bytes, bool } from 'moroutine'; + +describe('bytes', () => { + it('bytes(n) creates standalone byte buffer', () => { + const buf = bytes(16); + const data = buf.load(); + assert.equal(data.length, 16); + assert.equal(data[0], 0); + }); + + it('shared(bytes(n)) creates byte buffer', () => { + const buf = shared(bytes(16)); + assert.equal(buf.load().length, 16); + }); + + it('load returns view (not copy)', () => { + const buf = bytes(4); + buf.view[0] = 42; + assert.equal(buf.load()[0], 42); + }); + + it('store writes exact length data', () => { + const buf = bytes(4); + buf.store(new Uint8Array([1, 2, 3, 4])); + assert.deepEqual([...buf.load()], [1, 2, 3, 4]); + }); + + it('store throws if length does not match', () => { + const buf = bytes(4); + assert.throws(() => buf.store(new Uint8Array([1, 2, 3])), /length/i); + assert.throws(() => buf.store(new Uint8Array([1, 2, 3, 4, 5])), /length/i); + }); + + it('view provides mutable direct access', () => { + const buf = bytes(4); + buf.view[0] = 0xff; + buf.view[3] = 0xaa; + assert.equal(buf.load()[0], 0xff); + assert.equal(buf.load()[3], 0xaa); + }); + + it('bytes in a struct schema', () => { + const s = shared({ data: bytes(8), flag: bool }); + s.fields.data.store(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8])); + s.fields.flag.store(true); + assert.deepEqual([...s.fields.data.load()], [1, 2, 3, 4, 5, 6, 7, 8]); + assert.equal(s.fields.flag.load(), true); + }); + + it('bytes in struct shares one buffer', () => { + const s = shared({ data: bytes(8), flag: bool }); + const SHARED = Symbol.for('moroutine.shared'); + const dataSer = (s.fields.data as any)[SHARED](); + const flagSer = (s.fields.flag as any)[SHARED](); + assert.equal(dataSer.buffer, flagSer.buffer); + }); +}); -- 2.51.2