UNPKG

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