Files
smpp-js/src/reassembly.ts
T
lilleman c3ebd6d6bc
Test / lint (pull_request) Successful in 25s
Test / test (22) (pull_request) Successful in 32s
Test / test (18) (pull_request) Successful in 33s
Test / test (20) (pull_request) Successful in 32s
Test / test (24) (pull_request) Successful in 31s
Test / test (26) (pull_request) Successful in 31s
Mirror / push (push) Successful in 6s
Cap the heap unanswered messages hold, and detach them from the chunk they arrived in
2026-09-25 19:22:31 +02:00

240 lines
6.8 KiB
TypeScript

import type { Concat } from './concat.ts';
import type { PduObject } from './pdu.ts';
import type { SmppLog } from './log.ts';
import { ExpiringGroups } from './expiring-groups.ts';
import { decodeMessage } from './message.ts';
import { detach, retainedOctets } from './retained-pdu.ts';
import { messageOctets } from './message-body.ts';
import { paramNumber, paramText } from './defs/types.ts';
import { uuidv7 } from './uuid.ts';
/** A concatenated message given up on, whose segments the peer has already been answered for. */
export type LostGroup = {
parts: number;
reason: 'evicted' | 'expired' | 'linkGone';
smsId: string;
total: number;
};
export type ReassemblerOptions = {
log: SmppLog;
max: number;
maxOctets?: number | undefined;
/** Injected so the ids a group is answered with can be read back in a test. */
newId?: (() => string) | undefined;
/** Injected so expiry can be exercised without a wall clock. */
now?: (() => number) | undefined;
onLost: (lost: LostGroup) => void;
timeout: number;
};
/** Why a segment was not kept: its header joins no message here, or the store had no room for it. */
export type Refusal = 'full' | 'unplaceable';
/** What a segment did to its group. */
export type Collected =
| { kept: false; refusal: Refusal }
| {
kept: true;
/** The id base the group's segments are answered with. */
smsId: string;
/** Every segment in order, on the one that completes the message. */
whole?: PduObject[] | undefined;
};
export const defaultMaxOctets = 64 * 1024 * 1024;
type Group = {
octets: number;
parts: Map<number, PduObject>;
smsId: string;
total: number;
};
// NUL: the one octet a C-Octet String address cannot hold, so no sender can forge another's key.
function groupKey(pduObj: PduObject, concat: Concat): string {
return [
paramText(pduObj.params.source_addr),
paramText(pduObj.params.destination_addr),
concat.spelling,
String(concat.reference),
].join('\u0000');
}
/** The text of a message, joining its segments in the order they were reassembled. */
export function decodeSegments(pduObjs: PduObject[]): string {
let message = '';
for (const pduObj of pduObjs) {
const part = messageOctets(pduObj);
if (part === undefined) continue;
message += decodeMessage(
part,
paramNumber(pduObj.params.data_coding, 0),
paramNumber(pduObj.params.esm_class, 0),
).message;
}
return message;
}
/** Holds the segments of incomplete multipart messages until they are whole, capped and expiring. */
export class Reassembler {
private readonly groups: ExpiringGroups<Group>;
private readonly log: SmppLog;
private readonly max: number;
private readonly maxOctets: number;
private readonly newId: () => string;
private readonly onLost: (lost: LostGroup) => void;
private octets = 0;
constructor(options: ReassemblerOptions) {
this.groups = new ExpiringGroups<Group>({
max: options.max,
now: options.now,
onSweep: () => { this.sweep(); },
timeout: options.timeout,
});
this.log = options.log;
this.max = options.max;
this.maxOctets = options.maxOctets ?? defaultMaxOctets;
this.newId = options.newId ?? uuidv7;
this.onLost = options.onLost;
}
get size(): number {
return this.groups.size;
}
/** The group the segment joined, and always an answer for it: an unanswered one stalls a peer. */
collect(pduObj: PduObject, concat: Concat): Collected {
this.sweep();
const key = groupKey(pduObj, concat);
const existing = this.groups.get(key);
if (!this.placeable(concat, existing)) return { kept: false, refusal: 'unplaceable' };
const group = existing ?? this.open(key, concat.total);
const replaced = group.parts.get(concat.part);
const segment = detach(pduObj);
const delta = retainedOctets(segment) - (replaced === undefined ? 0 : retainedOctets(replaced));
group.parts.set(concat.part, segment);
group.octets += delta;
this.octets += delta;
if (group.parts.size < group.total) {
this.trim(key);
// Its own arrival overran the octet cap, so the peer keeps it rather than being told we did.
if (this.groups.get(key) !== group) return { kept: false, refusal: 'full' };
return { kept: true, smsId: group.smsId };
}
this.groups.delete(key);
this.octets -= group.octets;
return {
kept: true,
smsId: group.smsId,
whole: [...group.parts.entries()].sort(([a], [b]) => a - b).map(([, part]) => part),
};
}
clear(): void {
for (const [, group] of this.groups.takeAll()) {
this.lost(group, 'linkGone');
}
this.octets = 0;
}
/** Drops every group past its deadline. Runs before each collect and on its own timer. */
sweep(): void {
for (const [, group] of this.groups.takeExpired()) {
this.octets -= group.octets;
this.lost(group, 'expired');
}
}
/** Whether a segment can join a group at all: its own numbering, and the group's total. */
private placeable(concat: Concat, existing: Group | undefined): boolean {
if (concat.part < 1 || concat.total < 1 || concat.part > concat.total) {
this.log.warn('reassembler - dropping an impossibly numbered segment', {
part: concat.part,
total: concat.total,
});
return false;
}
// Parts 1/2 and 2/3 would otherwise complete the stored two-part group as a truncated message.
if (existing && existing.total !== concat.total) {
this.log.warn('reassembler - dropping a segment with an inconsistent total', {
existingTotal: existing.total,
part: concat.part,
total: concat.total,
});
return false;
}
return true;
}
private open(key: string, total: number): Group {
if (this.groups.full) this.dropOldest();
const group: Group = { octets: 0, parts: new Map(), smsId: this.newId(), total };
this.groups.set(key, group);
return group;
}
/** Drops the oldest groups until the retained payload is back under the octet cap. */
private trim(current: string): void {
while (this.octets > this.maxOctets) {
const oldest = this.takeOldest();
if (!oldest) return;
// The refused segment is in the group but stays with the peer, so it is none of the loss.
const answered = oldest[0] === current ? oldest[1].parts.size - 1 : oldest[1].parts.size;
if (answered > 0) this.lost(oldest[1], 'evicted', answered);
}
}
private takeOldest(): [string, Group] | undefined {
const oldest = this.groups.takeOldest();
if (oldest) this.octets -= oldest[1].octets;
return oldest;
}
private dropOldest(): void {
const oldest = this.takeOldest();
if (oldest) this.lost(oldest[1], 'evicted');
}
/** Its segments are answered, so the peer will not send them again: this is traffic gone. */
private lost(group: Group, reason: LostGroup['reason'], parts = group.parts.size): void {
const lost: LostGroup = { parts, reason, smsId: group.smsId, total: group.total };
this.log.warn('reassembler - gave up a concatenated message', {
...lost,
max: this.max,
maxOctets: this.maxOctets,
octets: this.octets,
});
this.onLost(lost);
}
}