@sp8d/core
Version:
sp8d-core: Ultra-low latency, memory-safe concurrency primitives for TypeScript/JavaScript. Lock-free channels with comprehensive safety guarantees, race condition prevention, and zero-copy messaging for browser and Node.js applications.
542 lines (541 loc) • 17.9 kB
JavaScript
// src/sp8d-core.ts
var STATUS_EMPTY = 0;
var STATUS_CLAIMED = 1;
var STATUS_READY = 2;
var DEFAULT_SWEEP = 50;
var META_BYTES = 40;
var META_FIELDS = {
slots: 0,
slotSize: 1,
mode: 2,
segments: 3,
sweepTimeoutMs: 4
};
function alignTo4(value) {
return value % 4 === 0 ? 0 : 4 - value % 4;
}
function modeToNum(mode) {
return mode === "SPSC" ? 0 : mode === "MPSC" ? 1 : 2;
}
function numToMode(n) {
return n === 0 ? "SPSC" : n === 1 ? "MPSC" : "MPMC";
}
var SegmentMeta = class {
head;
tail;
count;
constructor(base, sab, slots) {
this.head = new Int32Array(sab, base + 0, 1);
this.tail = new Int32Array(sab, base + 4, 1);
this.count = new Int32Array(sab, base + 8, 1);
}
};
var ChannelCore = class _ChannelCore {
/** SharedArrayBuffer backing this channel. */
sab;
/** Segment metadata (head, tail, count) for each segment. */
segmentMetas;
/** Slot status arrays (diagnostics only, readonly). */
slotStatus;
/** Slot generation/cycle tag arrays (diagnostics only, readonly). */
slotGeneration;
/** Slot claim timestamp arrays (diagnostics only, readonly). */
slotClaimTimestamp;
/** Slot payload arrays (internal use). */
payload;
slots;
segments;
messageSize;
mode;
// Make these private for security
errors = 0;
conflicts = 0;
reclaimed = 0;
closed = false;
sweepTimeoutMs;
sweeperInterval;
meta;
constructor(sab, slots, slotSize, mode = "SPSC", segments = 1, sweepTimeoutMs = DEFAULT_SWEEP) {
this.sab = sab;
this.meta = new Uint32Array(sab, 0, META_BYTES / 4);
this.slots = slots;
this.segments = segments;
this.messageSize = slotSize;
this.mode = mode;
this.sweepTimeoutMs = sweepTimeoutMs;
let offset = META_BYTES;
this.segmentMetas = [];
this.slotStatus = [];
this.slotGeneration = [];
this.slotClaimTimestamp = [];
this.payload = [];
for (let seg = 0; seg < segments; seg++) {
this.segmentMetas.push(new SegmentMeta(offset, sab, slots));
offset += 12;
this.slotStatus.push(new Uint8Array(sab, offset, slots));
offset += slots;
offset += alignTo4(offset);
this.slotGeneration.push(new Uint8Array(sab, offset, slots));
offset += slots;
offset += alignTo4(offset);
this.slotClaimTimestamp.push(new Uint32Array(sab, offset, slots));
offset += slots * 4;
this.payload.push(new Uint8Array(sab, offset, slots * slotSize));
offset += slots * slotSize;
}
this.reclaimed = 0;
this.sweeperInterval = setInterval(
() => this.sweepStaleSlots(),
Math.max(this.sweepTimeoutMs, 10)
);
}
static fromBuffer(sab) {
if (sab.byteLength < META_BYTES) {
throw new Error(
`Invalid buffer: too small (${sab.byteLength} < ${META_BYTES})`
);
}
const meta = new Uint32Array(sab, 0, META_BYTES / 4);
const slots = meta[META_FIELDS.slots];
const slotSize = meta[META_FIELDS.slotSize];
const segments = meta[META_FIELDS.segments];
if (!slots || slots <= 0) throw new Error("Invalid buffer: invalid slots");
if (!slotSize || slotSize <= 0)
throw new Error("Invalid buffer: invalid slotSize");
if (!segments || segments <= 0)
throw new Error("Invalid buffer: invalid segments");
const perSegHeader = 12;
const perSegStatus = slots;
const perSegStatusAligned = perSegStatus + alignTo4(perSegStatus);
const perSegGeneration = slots;
const perSegGenerationAligned = perSegGeneration + alignTo4(perSegGeneration);
const perSegTimestamp = slots * 4;
const perSegPayload = slots * slotSize;
const perSeg = perSegHeader + perSegStatusAligned + perSegGenerationAligned + perSegTimestamp + perSegPayload;
const expectedSize = META_BYTES + perSeg * segments;
if (sab.byteLength < expectedSize) {
throw new Error(
`Invalid buffer: size mismatch (${sab.byteLength} < ${expectedSize})`
);
}
return new _ChannelCore(
sab,
slots,
slotSize,
numToMode(meta[META_FIELDS.mode]),
segments,
meta[META_FIELDS.sweepTimeoutMs]
);
}
/**
* Get a monotonic timestamp that handles 32-bit overflow gracefully.
* This prevents wraparound issues in long-running applications.
* @returns 32-bit timestamp value
*/
getMonotonicTimestamp() {
const now = typeof performance !== "undefined" && performance.now ? performance.now() + performance.timeOrigin : Date.now();
return Math.abs(Math.floor(now)) >>> 0;
}
pickSegment(producerId) {
return this.segments === 1 ? 0 : producerId !== void 0 ? Math.abs(producerId) % this.segments : 0;
}
/**
* Send a message. Throws if the channel is full or payload is too large.
* @param payload Message to send (ArrayBufferView)
* @param producerId Optional producer ID for multi-segment routing
* @returns true if sent, throws otherwise
*/
send(payload, producerId) {
if (this.closed) throw new Error("Channel is closed");
const binary = new Uint8Array(
payload.buffer,
payload.byteOffset,
payload.byteLength
);
if (binary.byteLength > this.messageSize) {
this.errors++;
throw new Error(
`Payload too large (${binary.byteLength}), max=${this.messageSize}`
);
}
const seg = this.pickSegment(producerId);
const { head, count } = this.segmentMetas[seg];
const statusArr = this.slotStatus[seg];
const genArr = this.slotGeneration[seg];
const claimTS = this.slotClaimTimestamp[seg];
const payloadArr = this.payload[seg];
for (let tries = 0; tries < this.slots; tries++) {
const localHead = Atomics.load(head, 0) % this.slots;
if (Atomics.load(statusArr, localHead) !== STATUS_EMPTY) {
this.conflicts++;
continue;
}
if (Atomics.compareExchange(
statusArr,
localHead,
STATUS_EMPTY,
STATUS_CLAIMED
) === STATUS_EMPTY) {
if (localHead < 0 || localHead >= this.slots) {
this.errors++;
throw new Error(
`Protocol corruption: slot index ${localHead} out of bounds [0, ${this.slots})`
);
}
const newGen = Atomics.load(genArr, localHead) + 1 & 255;
Atomics.store(genArr, localHead, newGen);
const timestamp = this.getMonotonicTimestamp();
Atomics.store(claimTS, localHead, timestamp);
const offset = localHead * this.messageSize;
if (offset + this.messageSize > payloadArr.length) {
this.errors++;
throw new Error(
`Protocol corruption: payload offset ${offset} + ${this.messageSize} exceeds buffer length ${payloadArr.length}`
);
}
payloadArr.set(binary.subarray(0, this.messageSize), offset);
Atomics.store(statusArr, localHead, STATUS_READY);
Atomics.add(count, 0, 1);
const expectedHead = localHead;
const newHead = (localHead + 1) % this.slots;
Atomics.compareExchange(head, 0, expectedHead, newHead);
return true;
}
this.conflicts++;
}
return false;
}
/**
* Try to send a message. Returns false if the channel is full or payload is too large.
* @param payload Message to send (ArrayBufferView)
* @param producerId Optional producer ID for multi-segment routing
* @returns true if sent, false otherwise
*/
trySend(payload, producerId) {
try {
return this.send(payload, producerId);
} catch {
return false;
}
}
/**
* Send a JSON-serializable object. Throws if the channel is full or payload is too large.
* @param obj Object to send
* @param producerId Optional producer ID for multi-segment routing
* @returns true if sent, throws otherwise
*/
sendJSON(obj, producerId) {
const bin = new TextEncoder().encode(JSON.stringify(obj));
return this.send(bin, producerId);
}
/**
* Receive a message. Returns null if the channel is empty.
* @returns Uint8Array or null
*/
recv() {
if (this.closed) return null;
for (let seg = 0; seg < this.segments; seg++) {
const { tail, count } = this.segmentMetas[seg];
const statusArr = this.slotStatus[seg];
const genArr = this.slotGeneration[seg];
const claimTS = this.slotClaimTimestamp[seg];
const payloadArr = this.payload[seg];
for (let tries = 0; tries < this.slots; tries++) {
const localTail = Atomics.load(tail, 0) % this.slots;
if (Atomics.load(statusArr, localTail) !== STATUS_READY) {
this.conflicts++;
continue;
}
if (Atomics.compareExchange(
statusArr,
localTail,
STATUS_READY,
STATUS_CLAIMED
) === STATUS_READY) {
if (localTail < 0 || localTail >= this.slots) {
this.errors++;
throw new Error(
`Protocol corruption: slot index ${localTail} out of bounds [0, ${this.slots})`
);
}
const offset = localTail * this.messageSize;
if (offset + this.messageSize > payloadArr.length) {
this.errors++;
throw new Error(
`Protocol corruption: payload offset ${offset} + ${this.messageSize} exceeds buffer length ${payloadArr.length}`
);
}
const buf = payloadArr.slice(offset, offset + this.messageSize);
const newGen = Atomics.load(genArr, localTail) + 1 & 255;
Atomics.store(genArr, localTail, newGen);
Atomics.store(statusArr, localTail, STATUS_EMPTY);
Atomics.store(claimTS, localTail, 0);
Atomics.sub(count, 0, 1);
const expectedTail = localTail;
const newTail = (localTail + 1) % this.slots;
Atomics.compareExchange(tail, 0, expectedTail, newTail);
return buf;
}
this.conflicts++;
}
}
return null;
}
/**
* Try to receive a message. Returns null if the channel is empty.
* @returns Uint8Array or null
*/
tryRecv() {
try {
return this.recv();
} catch {
return null;
}
}
/**
* Receive a JSON-serialized object. Returns null if the channel is empty or parse fails.
* @returns object or null
*/
recvJSON() {
const bin = this.recv();
if (!bin) return null;
try {
return JSON.parse(new TextDecoder().decode(bin));
} catch {
return null;
}
}
/**
* Async iterator for receiving messages.
*/
async *[Symbol.asyncIterator]() {
while (!this.closed) {
const value = await this.recvAsync();
if (value != null) yield value;
}
}
/**
* Receive a message asynchronously. Resolves when a message is available or channel is closed.
* @returns Promise<Uint8Array | null>
*/
async recvAsync() {
while (!this.closed) {
const val = this.recv();
if (val !== null) return val;
await new Promise((r) => setTimeout(r, 1));
}
return null;
}
/**
* Returns true if the channel is full.
*/
full() {
if (this.closed) return false;
for (let seg = 0; seg < this.segments; seg++) {
if (Atomics.load(this.segmentMetas[seg].count, 0) < this.slots)
return false;
}
return true;
}
/**
* Returns true if the channel is empty.
*/
empty() {
if (this.closed) return true;
for (let seg = 0; seg < this.segments; seg++) {
if (Atomics.load(this.segmentMetas[seg].count, 0) > 0) return false;
}
return true;
}
/**
* Close the channel and stop all background tasks.
*/
close() {
this.closed = true;
clearInterval(this.sweeperInterval);
for (let seg = 0; seg < this.segments; seg++) {
Atomics.store(this.segmentMetas[seg].head, 0, 0);
Atomics.store(this.segmentMetas[seg].tail, 0, 0);
Atomics.store(this.segmentMetas[seg].count, 0, 0);
const statusArr = this.slotStatus[seg];
const claimTS = this.slotClaimTimestamp[seg];
for (let i = 0; i < this.slots; i++) {
Atomics.store(statusArr, i, STATUS_EMPTY);
Atomics.store(claimTS, i, 0);
}
}
}
/**
* Asynchronously close the channel, waiting for all background tasks to stop.
* @returns Promise<void>
*/
async closeAsync() {
this.close();
await new Promise((r) => setTimeout(r, 0));
}
/**
* Reset the channel to its initial state (empties all slots, resets counters).
* Does not reallocate the buffer.
*/
reset() {
this.close();
for (let seg = 0; seg < this.segments; seg++) {
Atomics.store(this.segmentMetas[seg].head, 0, 0);
Atomics.store(this.segmentMetas[seg].tail, 0, 0);
Atomics.store(this.segmentMetas[seg].count, 0, 0);
const statusArr = this.slotStatus[seg];
const genArr = this.slotGeneration[seg];
const claimTS = this.slotClaimTimestamp[seg];
for (let i = 0; i < this.slots; i++) {
Atomics.store(statusArr, i, STATUS_EMPTY);
Atomics.store(genArr, i, 0);
Atomics.store(claimTS, i, 0);
}
}
this.errors = 0;
this.conflicts = 0;
this.reclaimed = 0;
this.closed = false;
this.sweeperInterval = setInterval(
() => this.sweepStaleSlots(),
Math.max(this.sweepTimeoutMs, 10)
);
}
/**
* Get current channel statistics.
*/
stats() {
let used = 0;
for (let seg = 0; seg < this.segments; seg++) {
used += Atomics.load(this.segmentMetas[seg].count, 0);
}
return {
slots: this.segments * this.slots,
used,
free: this.segments * this.slots - used,
stalled: 0,
errors: this.errors,
conflicts: this.conflicts,
reclaimed: this.reclaimed
};
}
/**
* Get a human-readable info string for this channel.
*/
info() {
return `SP8D Channel, mode=${this.mode}, slots=${this.slots}, segments=${this.segments}`;
}
/**
* Validate the channel's internal state. Throws if protocol invariants are violated.
*/
validate() {
for (let seg = 0; seg < this.segments; seg++) {
for (let i = 0; i < this.slots; i++) {
const st = Atomics.load(this.slotStatus[seg], i);
const gen = Atomics.load(this.slotGeneration[seg], i);
if (![STATUS_EMPTY, STATUS_CLAIMED, STATUS_READY].includes(st))
throw new Error(
`Protocol violation: Unknown slot status ${st} at seg:${seg} idx:${i}`
);
if (typeof gen !== "number" || gen < 0 || gen > 255)
throw new Error(
`Protocol violation: Invalid generation ${gen} at seg:${seg} idx:${i}`
);
}
}
}
sweepStaleSlots() {
if (this.closed) return;
const now = this.getMonotonicTimestamp();
for (let seg = 0; seg < this.segments; seg++) {
const statusArr = this.slotStatus[seg];
const genArr = this.slotGeneration[seg];
const claimTS = this.slotClaimTimestamp[seg];
const count = this.segmentMetas[seg].count;
for (let i = 0; i < this.slots; i++) {
const status = Atomics.load(statusArr, i);
const ts = Atomics.load(claimTS, i);
if (status === STATUS_CLAIMED && ts !== 0 && now - Number(ts) > this.sweepTimeoutMs) {
if (Atomics.compareExchange(
statusArr,
i,
STATUS_CLAIMED,
STATUS_EMPTY
) === STATUS_CLAIMED) {
Atomics.store(genArr, i, Atomics.load(genArr, i) + 1 & 255);
Atomics.store(claimTS, i, 0);
const used = Atomics.load(count, 0);
if (used > 0) Atomics.sub(count, 0, 1);
this.reclaimed++;
this.errors++;
}
}
}
}
}
/**
* Send a message asynchronously. Waits until a slot is available or timeout/abort.
* Uses polling for browser compatibility (Atomics.wait is not available on main thread).
* Never queues messages internally; each call only waits for a slot, then sends.
* @param payload Message to send (ArrayBufferView)
* @param producerId Optional producer ID for multi-segment routing
* @param opts Optional: { timeoutMs?: number, signal?: AbortSignal }
* @returns Promise<boolean> Resolves true if sent, false if timeout/abort
*/
async sendAsync(payload, producerId, opts) {
const start = Date.now();
while (true) {
if (opts?.signal?.aborted) return false;
try {
if (this.send(payload, producerId)) return true;
} catch (e) {
if (String(e).includes("Payload too large")) throw e;
}
if (opts?.timeoutMs && Date.now() - start > opts.timeoutMs) return false;
await new Promise((r) => setTimeout(r, 2));
}
}
};
function createChannel(options) {
const {
slots,
slotSize,
mode = "SPSC",
segments = 1,
sweepTimeoutMs = DEFAULT_SWEEP
} = options;
const perSegHeader = 12;
const perSegStatus = slots;
const perSegStatusAligned = perSegStatus + alignTo4(perSegStatus);
const perSegGeneration = slots;
const perSegGenerationAligned = perSegGeneration + alignTo4(perSegGeneration);
const perSegTimestamp = slots * 4;
const perSegPayload = slots * slotSize;
const perSeg = perSegHeader + perSegStatusAligned + perSegGenerationAligned + perSegTimestamp + perSegPayload;
const bufferSize = META_BYTES + perSeg * segments;
const sab = new SharedArrayBuffer(bufferSize);
const meta = new Uint32Array(sab, 0, META_BYTES / 4);
meta[META_FIELDS.slots] = slots;
meta[META_FIELDS.slotSize] = slotSize;
meta[META_FIELDS.mode] = modeToNum(mode);
meta[META_FIELDS.segments] = segments;
meta[META_FIELDS.sweepTimeoutMs] = sweepTimeoutMs;
const channel = new ChannelCore(
sab,
slots,
slotSize,
mode,
segments,
sweepTimeoutMs
);
return { channel, buffer: sab };
}
function attachChannel(buffer) {
return ChannelCore.fromBuffer(buffer);
}
export {
ChannelCore,
attachChannel,
createChannel
};
//# sourceMappingURL=sp8d-core.js.map