Answer every segment of an inbound concatenated message as it arrives (#83)
* Regression tests for answering every inbound segment as it arrives * Answer every segment of an inbound concatenated message as it arrives * Record the segment-by-segment answer in AGENTS.md and README * Regression tests for an empty message_id on deliver_sm_resp * Answer a deliver_sm with the empty message_id SMPP 3.4 makes it * Record Jasmin's refusal of a deliver_sm_resp message_id * Mark the Jasmin multipart deadlock fixed * Regression tests for the architecture review's findings * Give the segment id notation an owner, and every segment a status * Correct what the ids reach and what a lost group tells the application * Regression tests for a group lost to its own octet overrun * Report a group lost to its own overrun, and refuse by the command it arrived on * Keep the docs true about what a segment is answered with * Count only the answered segments of a group lost to an overrun * Report only what a lost group cost, and say which cap bit
This commit is contained in:
+103
-36
@@ -6,21 +6,48 @@ import type { Tlv } from './defs/tlvs.ts';
|
||||
import { ExpiringGroups } from './expiring-groups.ts';
|
||||
import { decodeMessage } from './message.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;
|
||||
};
|
||||
|
||||
const defaultMaxOctets = 64 * 1024 * 1024;
|
||||
|
||||
type Group = {
|
||||
octets: number;
|
||||
parts: Map<number, PduObject>;
|
||||
smsId: string;
|
||||
total: number;
|
||||
};
|
||||
|
||||
@@ -101,6 +128,8 @@ export class Reassembler {
|
||||
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) {
|
||||
@@ -113,38 +142,22 @@ export class Reassembler {
|
||||
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;
|
||||
}
|
||||
|
||||
/** Every segment in order, on the one that completes the message; nothing while it is short. */
|
||||
collect(pduObj: PduObject, concat: ConcatInfo): PduObject[] | undefined {
|
||||
/** The group the segment joined, and always an answer for it: an unanswered one stalls a peer. */
|
||||
collect(pduObj: PduObject, concat: ConcatInfo): Collected {
|
||||
this.sweep();
|
||||
|
||||
if (concat.part < 1 || concat.total < 1 || concat.part > concat.total) {
|
||||
this.log.warn('reassembler - dropping a segment the UDH numbers impossibly', {
|
||||
part: concat.part,
|
||||
total: concat.total,
|
||||
});
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const key = groupKey(pduObj, concat.reference);
|
||||
const existing = this.groups.get(key);
|
||||
|
||||
// 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 UDH total', {
|
||||
existingTotal: existing.total,
|
||||
part: concat.part,
|
||||
total: concat.total,
|
||||
});
|
||||
|
||||
return undefined;
|
||||
}
|
||||
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);
|
||||
@@ -156,34 +169,69 @@ export class Reassembler {
|
||||
this.octets += delta;
|
||||
|
||||
if (group.parts.size < group.total) {
|
||||
this.trim();
|
||||
this.trim(key);
|
||||
|
||||
return undefined;
|
||||
// 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 [...group.parts.entries()].sort(([a], [b]) => a - b).map(([, part]) => part);
|
||||
return {
|
||||
kept: true,
|
||||
smsId: group.smsId,
|
||||
whole: [...group.parts.entries()].sort(([a], [b]) => a - b).map(([, part]) => part),
|
||||
};
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.groups.clear();
|
||||
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 [key, group] of this.groups.takeExpired()) {
|
||||
this.log.info('reassembler - incomplete message expired', { key, total: group.total });
|
||||
for (const [, group] of this.groups.takeExpired()) {
|
||||
this.octets -= group.octets;
|
||||
this.lost(group, 'expired');
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a segment's UDH can join a group at all: its own numbering, and the group's total. */
|
||||
private placeable(concat: ConcatInfo, existing: Group | undefined): boolean {
|
||||
if (concat.part < 1 || concat.total < 1 || concat.part > concat.total) {
|
||||
this.log.warn('reassembler - dropping a segment the UDH numbers impossibly', {
|
||||
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 UDH 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(), total };
|
||||
const group: Group = { octets: 0, parts: new Map(), smsId: this.newId(), total };
|
||||
|
||||
this.groups.set(key, group);
|
||||
|
||||
@@ -191,24 +239,43 @@ export class Reassembler {
|
||||
}
|
||||
|
||||
/** Drops the oldest groups until the retained payload is back under the octet cap. */
|
||||
private trim(): void {
|
||||
while (this.octets > this.maxOctets && this.groups.size > 0) {
|
||||
this.dropOldest();
|
||||
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 dropOldest(): void {
|
||||
private takeOldest(): [string, Group] | undefined {
|
||||
const oldest = this.groups.takeOldest();
|
||||
|
||||
if (!oldest) return;
|
||||
if (oldest) this.octets -= oldest[1].octets;
|
||||
|
||||
const [, group] = oldest;
|
||||
return oldest;
|
||||
}
|
||||
|
||||
this.log.warn('reassembler - buffer full, dropping the oldest message', {
|
||||
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.octets -= group.octets;
|
||||
this.onLost(lost);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user