UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

292 lines (264 loc) 11.6 kB
import { assert } from "../../../../core/assert.js"; import { FRAGMENT_NACK_INITIAL_DELAY_MS, FRAGMENT_NACK_MAX_ROUNDS, FRAGMENT_NACK_RESEND_INTERVAL_MS, MAX_CHUNKS_PER_MESSAGE, } from "./packet_size.js"; /** * Per-peer reassembly buffer for fragmented packets. * * When a sender's logical payload exceeds the MTU it gets split into * multiple {@link NetworkPacketType.FRAGMENT} packets, each carrying a * `(message_id, chunk_index, total_chunks, chunk_bytes)` tuple. The * receiver feeds those tuples to {@link receive}; once all chunks for a * `message_id` have arrived, `receive` returns the reassembled bytes * for the upper layer to dispatch. * * Loss recovery via NACK: a partially-received message that doesn't * complete within `nack_initial_delay_ms` triggers a NACK back to the * sender listing the missing chunk indices. NACK rounds repeat at * `nack_resend_interval_ms` up to `nack_max_rounds`; after that the * receiver gives up on the message and the sender's retention ages * out independently. Wire the per-tick driver via {@link service}. * * Loss-of-last-resort model: if even the NACK retries fail (link is * dead, sender retention was evicted, etc.) the message never * completes and FIFO-evicts when the pending cap is hit. Upper layers * are still expected to tolerate occasional missing messages — NACK * recovers most loss, not all. * * @author Alex Goldring * @copyright Company Named Limited (c) 2025 */ export class FragmentAssembler { #pending; #order; #nack_scratch; #drop_scratch; /** * @param {{ * max_pending_messages?: number, * max_message_size?: number, * nack_initial_delay_ms?: number, * nack_resend_interval_ms?: number, * nack_max_rounds?: number, * }} [options] * `max_pending_messages` caps the number of in-flight reassembly * slots; when full, the oldest pending message is evicted to make * room. `max_message_size` rejects reassembled messages larger than * this many bytes (defensive against a malicious peer or a wedged * sender). The `nack_*` knobs control the retransmit-request timer * driven by {@link service}. */ constructor({ max_pending_messages = 8, max_message_size = 65536, nack_initial_delay_ms = FRAGMENT_NACK_INITIAL_DELAY_MS, nack_resend_interval_ms = FRAGMENT_NACK_RESEND_INTERVAL_MS, nack_max_rounds = FRAGMENT_NACK_MAX_ROUNDS, } = {}) { assert.isPositiveInteger(max_pending_messages, 'max_pending_messages'); assert.isPositiveInteger(max_message_size, 'max_message_size'); assert.isPositiveInteger(nack_initial_delay_ms, 'nack_initial_delay_ms'); assert.isPositiveInteger(nack_resend_interval_ms, 'nack_resend_interval_ms'); assert.isPositiveInteger(nack_max_rounds, 'nack_max_rounds'); /** @type {number} @readonly */ this.max_pending_messages = max_pending_messages; /** @type {number} @readonly */ this.max_message_size = max_message_size; /** @type {number} @readonly */ this.nack_initial_delay_ms = nack_initial_delay_ms; /** @type {number} @readonly */ this.nack_resend_interval_ms = nack_resend_interval_ms; /** @type {number} @readonly */ this.nack_max_rounds = nack_max_rounds; /** * Pending reassembly state per `message_id`. Each entry: * total_chunks: uint8 — number of chunks expected * chunks: (Uint8Array | undefined)[] — chunk_index → bytes * received_count: number of distinct chunks present * total_bytes: sum of chunk lengths received so far * first_seen_at: ms timestamp of the first {@link service} tick * that observed this entry; -1 until stamped * last_nack_at: ms timestamp of the most recent NACK emitted * for this entry; -1 if never * nack_rounds: number of NACKs emitted for this entry * @type {Map<number, {total_chunks: number, chunks: Uint8Array[], received_count: number, total_bytes: number, first_seen_at: number, last_nack_at: number, nack_rounds: number}>} * @private */ this.#pending = new Map(); /** * FIFO of message_ids in insertion order, for eviction when the * pending map exceeds `max_pending_messages`. * @type {number[]} * @private */ this.#order = []; /** * Reusable scratch for the missing-index list passed to the NACK * callback. Sized to the wire maximum so the callback can always * iterate `count` entries safely. * @type {Uint8Array} * @private */ this.#nack_scratch = new Uint8Array(MAX_CHUNKS_PER_MESSAGE); /** * Scratch for message_ids to drop after a {@link service} pass. * Collected during iteration so the underlying map isn't mutated * mid-iteration. * @type {number[]} * @private */ this.#drop_scratch = []; } /** * Process an incoming fragment. Returns the reassembled bytes when * the message is complete, or null otherwise. * * @param {number} message_id sender-assigned message id * @param {number} chunk_index 0-based * @param {number} total_chunks expected number of chunks (>= 1) * @param {Uint8Array} chunk_bytes source byte array * @param {number} chunk_offset start offset within `chunk_bytes` * @param {number} chunk_length number of bytes from `chunk_offset` to consume * @returns {Uint8Array|null} */ receive(message_id, chunk_index, total_chunks, chunk_bytes, chunk_offset, chunk_length) { assert.isNonNegativeInteger(message_id, 'message_id'); assert.isNonNegativeInteger(chunk_index, 'chunk_index'); assert.isPositiveInteger(total_chunks, 'total_chunks'); assert.isNonNegativeInteger(chunk_offset, 'chunk_offset'); assert.isNonNegativeInteger(chunk_length, 'chunk_length'); if (chunk_index >= total_chunks) { // Malformed; discard silently. return null; } let entry = this.#pending.get(message_id); if (entry === undefined) { if (this.#pending.size >= this.max_pending_messages) { // Evict the oldest pending message (FIFO). const oldest = this.#order.shift(); if (oldest !== undefined) this.#pending.delete(oldest); } entry = { total_chunks, chunks: new Array(total_chunks), received_count: 0, total_bytes: 0, first_seen_at: -1, last_nack_at: -1, nack_rounds: 0, }; this.#pending.set(message_id, entry); this.#order.push(message_id); } if (entry.total_chunks !== total_chunks) { // Conflicting metadata across fragments for the same message_id. // Drop the whole assembly — something is very wrong upstream. this.#delete(message_id); return null; } if (entry.chunks[chunk_index] !== undefined) { // Duplicate fragment; ignore (network may have retransmitted). return null; } // Defensive: refuse to allocate more memory than max_message_size for // this assembly. Check incrementally so a malicious sender can't // claim 255 chunks of 65 KB each. if (entry.total_bytes + chunk_length > this.max_message_size) { this.#delete(message_id); return null; } const copy = new Uint8Array(chunk_length); copy.set(chunk_bytes.subarray(chunk_offset, chunk_offset + chunk_length)); entry.chunks[chunk_index] = copy; entry.received_count++; entry.total_bytes += chunk_length; if (entry.received_count < entry.total_chunks) return null; // Complete — assemble. const reassembled = new Uint8Array(entry.total_bytes); let offset = 0; for (let i = 0; i < entry.total_chunks; i++) { const c = entry.chunks[i]; reassembled.set(c, offset); offset += c.length; } this.#delete(message_id); return reassembled; } /** * Per-tick maintenance: drive NACK emission for pending messages * whose initial delay has elapsed and whose resend interval has * lapsed, and drop messages whose NACK budget is exhausted. * * On the first service tick that observes a pending entry, its * arrival is timestamped (so the initial delay is measured from * `now_ms` of that tick rather than from the actual wall-clock * arrival of the first chunk — at typical tick rates the difference * is one tick, negligible vs. the 100 ms initial delay). * * `on_nack` is invoked once per eligible message with * `(message_id, indices, count)`. The `indices` argument is a * reused internal Uint8Array; only `indices[0..count)` is valid * and only for the duration of the call. * * @param {number} now_ms * @param {function(number, Uint8Array, number): void} on_nack */ service(now_ms, on_nack) { assert.isNumber(now_ms, 'now_ms'); assert.isFunction(on_nack, 'on_nack'); if (this.#pending.size === 0) return; this.#drop_scratch.length = 0; for (const [message_id, entry] of this.#pending) { if (entry.first_seen_at < 0) { entry.first_seen_at = now_ms; continue; } if (now_ms - entry.first_seen_at < this.nack_initial_delay_ms) continue; if (entry.last_nack_at >= 0 && now_ms - entry.last_nack_at < this.nack_resend_interval_ms) continue; // Build the missing-chunk list. let missing_count = 0; for (let i = 0; i < entry.total_chunks; i++) { if (entry.chunks[i] === undefined) { this.#nack_scratch[missing_count++] = i; } } // Defensive: a fully-received entry should have been deleted // in `receive`, but a torn-down concurrent state could leave // a zero-missing slot here. Skip rather than emit an empty NACK. if (missing_count === 0) continue; on_nack(message_id, this.#nack_scratch, missing_count); entry.last_nack_at = now_ms; entry.nack_rounds++; if (entry.nack_rounds >= this.nack_max_rounds) { this.#drop_scratch.push(message_id); } } for (let i = 0; i < this.#drop_scratch.length; i++) { this.#delete(this.#drop_scratch[i]); } } /** * Number of in-flight reassembly slots currently held. * @returns {number} */ pending_count() { return this.#pending.size; } /** * Drop all in-flight reassembly state. Useful on peer disconnect. */ clear() { this.#pending.clear(); this.#order.length = 0; } /** @private */ #delete(message_id) { this.#pending.delete(message_id); const idx = this.#order.indexOf(message_id); if (idx >= 0) this.#order.splice(idx, 1); } }