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