@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
195 lines (180 loc) • 6.66 kB
JavaScript
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);
}
}