UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

195 lines (180 loc) 6.66 kB
import { assert } from "../../../../core/assert.js"; import { FRAGMENT_RETENTION_MAX_AGE_MS, FRAGMENT_RETENTION_MAX_MESSAGES, FRAGMENT_RETENTION_MAX_RETRIES, } from "./packet_size.js"; /** * Per-peer sender-side retention buffer for fragmented message bytes, * used to satisfy NACK retransmits from the peer's {@link FragmentAssembler}. * * Lifecycle: * 1. {@link retain} — called on each fragmented send; copies the source * bytes into the retention so they're available to retransmit. If * the per-peer slot cap is hit, the oldest retained entry is FIFO- * evicted. * 2. {@link consume_nack} — looks up retained bytes for a NACK target * and bumps the retry counter. Returns `null` if the entry is gone * (aged out, evicted, or out of retry budget — in which case the * entry is dropped here too). * 3. {@link service} — call once per tick to age out entries older * than `max_age_ms`. * 4. {@link clear} — drop everything; call on peer disconnect. * * Memory cost: each retained entry holds a fresh copy of its source * payload (the upstream send buffer is a reused scratch). With the * default 16 slots × up-to-64 KiB cap on the receiver, worst case is * 1 MiB per peer; steady-state is empty because steady traffic fits in * one packet and bypasses fragmentation. * * @author Alex Goldring * @copyright Company Named Limited (c) 2025 */ export class FragmentRetention { #entries; #order; /** * @param {{ * max_messages?: number, * max_age_ms?: number, * max_retries?: number, * }} [options] * `max_messages` caps simultaneously-retained messages; oldest is * FIFO-evicted on overflow. `max_age_ms` is the per-entry TTL. * `max_retries` caps how many NACK retransmit rounds a single * retained message can satisfy before being dropped. */ constructor({ max_messages = FRAGMENT_RETENTION_MAX_MESSAGES, max_age_ms = FRAGMENT_RETENTION_MAX_AGE_MS, max_retries = FRAGMENT_RETENTION_MAX_RETRIES, } = {}) { assert.isPositiveInteger(max_messages, 'max_messages'); assert.isPositiveInteger(max_age_ms, 'max_age_ms'); assert.isNonNegativeInteger(max_retries, 'max_retries'); /** @type {number} @readonly */ this.max_messages = max_messages; /** @type {number} @readonly */ this.max_age_ms = max_age_ms; /** @type {number} @readonly */ this.max_retries = max_retries; /** * message_id → retained entry. `payload` is a fresh copy * (independent of the caller's send buffer). * @type {Map<number, {payload: Uint8Array, length: number, sent_at: number, retries: number}>} * @private */ this.#entries = new Map(); /** * FIFO of message_ids in insertion order, for eviction on * overflow and for monotonic age-out. * @type {number[]} * @private */ this.#order = []; } /** * Copy `payload[0..length)` into retention under `message_id`. * Evicts the oldest retained entry if `max_messages` is exceeded. * * If a retained entry already exists for `message_id` (only legitimate * cause: uint16 wrap), the old entry is dropped first so the FIFO * position and retry budget restart from the new send. * * @param {number} message_id * @param {Uint8Array} payload source bytes (caller may overwrite after return) * @param {number} length number of bytes from `payload[0]` to copy * @param {number} now_ms monotonic timestamp for age-out */ retain(message_id, payload, length, now_ms) { assert.isNonNegativeInteger(message_id, 'message_id'); assert.isNonNegativeInteger(length, 'length'); assert.isNumber(now_ms, 'now_ms'); if (this.#entries.has(message_id)) { this.#delete(message_id); } if (this.#entries.size >= this.max_messages) { const oldest = this.#order.shift(); if (oldest !== undefined) this.#entries.delete(oldest); } const copy = new Uint8Array(length); copy.set(payload.subarray(0, length)); this.#entries.set(message_id, { payload: copy, length, sent_at: now_ms, retries: 0, }); this.#order.push(message_id); } /** * Mark a NACK round for `message_id` and return the retained entry * so the caller can re-emit the missing chunks. Returns `null` if * no entry exists, or if the per-message retry budget is now * exhausted (in which case the entry is dropped). * * @param {number} message_id * @returns {{payload: Uint8Array, length: number}|null} */ consume_nack(message_id) { assert.isNonNegativeInteger(message_id, 'message_id'); const entry = this.#entries.get(message_id); if (entry === undefined) return null; entry.retries++; if (entry.retries > this.max_retries) { this.#delete(message_id); return null; } return entry; } /** * Per-tick maintenance: evict any entry older than `max_age_ms`. * * The FIFO is in strict insertion order, so we can stop at the first * still-fresh entry rather than scanning the whole map. * * @param {number} now_ms */ service(now_ms) { assert.isNumber(now_ms, 'now_ms'); while (this.#order.length > 0) { const id = this.#order[0]; const entry = this.#entries.get(id); if (entry === undefined) { this.#order.shift(); continue; } if (now_ms - entry.sent_at < this.max_age_ms) break; this.#order.shift(); this.#entries.delete(id); } } /** * Number of currently-retained messages. * @returns {number} */ size() { return this.#entries.size; } /** * Whether `message_id` is currently retained. * @param {number} message_id * @returns {boolean} */ has(message_id) { return this.#entries.has(message_id); } /** * Drop every retained entry. Use on peer disconnect. */ clear() { this.#entries.clear(); this.#order.length = 0; } /** @private */ #delete(message_id) { this.#entries.delete(message_id); const idx = this.#order.indexOf(message_id); if (idx >= 0) this.#order.splice(idx, 1); } }