Cap the heap unanswered messages hold, and detach them from the chunk they arrived in
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

This commit is contained in:
2026-09-25 19:22:31 +02:00
parent d8d0947e1f
commit c3ebd6d6bc
10 changed files with 155 additions and 73 deletions
+1
View File
@@ -70,6 +70,7 @@ src/
reassembly.ts Reassembler: capped, expiring multipart groups reassembly.ts Reassembler: capped, expiring multipart groups
reconnect-loop.ts ReconnectLoop: backoff, retry timer, stopped-ness reconnect-loop.ts ReconnectLoop: backoff, retry timer, stopped-ness
result.ts Result<T> — the shape every fallible call returns result.ts Result<T> — the shape every fallible call returns
retained-pdu.ts A PDU copied off the wire so holding it pins nothing else, and what holding it costs
send-sms.ts submitSms composition and the submitSmParams builder send-sms.ts submitSms composition and the submitSmParams builder
send-window.ts SendWindow: the maxOutstanding semaphore send-window.ts SendWindow: the maxOutstanding semaphore
session-options.ts SessionOptions, ReconnectOptions, bind direction and the session defaults session-options.ts SessionOptions, ReconnectOptions, bind direction and the session defaults
+3
View File
@@ -40,6 +40,9 @@
count as next to nothing, so a peer could hold far more than the cap. **Raise a `maxOctets` you count as next to nothing, so a peer could hold far more than the cap. **Raise a `maxOctets` you
tuned low**: it now holds several times fewer segments, and an incomplete message evicted over tuned low**: it now holds several times fewer segments, and an incomplete message evicted over
the cap is lost, since its segments were already answered. the cap is lost, since its segments were already answered.
- Unanswered `sms` messages are capped at 64 MiB per session by the `maxOctets` charge, beside the
1000-message cap: the oldest is dropped with a warning, as over the count. A peer faster than an
application answering asynchronously could hold gigabytes per session.
- `server()` refuses a `maxOctets` below 1 or not a whole number, `Infinity` included, like its - `server()` refuses a `maxOctets` below 1 or not a whole number, `Infinity` included, like its
other limits. `server({ maxOctets: 0 })` used to start and then refuse every multipart message. other limits. `server({ maxOctets: 0 })` used to start and then refuse every multipart message.
- `callback_num`, `callback_num_atag`, `callback_num_pres_ind`, `broadcast_area_identifier` and - `callback_num`, `callback_num_atag`, `callback_num_pres_ind`, `broadcast_area_identifier` and
+3 -2
View File
@@ -383,8 +383,9 @@ holds for `session.send()`.
message has failed. Answering through `sendReturn()` instead leaves the wait running. message has failed. Answering through `sendReturn()` instead leaves the wait running.
3. Tear down what is left, resolving to an `err` that says what was lost. 3. Tear down what is left, resolving to an `err` that says what was lost.
At most 1000 unanswered messages are held, for five minutes each; what falls out of either bound is At most 1000 unanswered messages, and 64 MiB of them by the `maxOctets` charge, are held for five
dropped with a warning on the log and waited for no longer. Neither bound is an option. minutes each; what falls out of a bound is dropped with a warning on the log and waited for no
longer. None of the bounds is an option.
`close({ signal })` cuts the wait short. `unbind()` takes no signal, and waits a further `close({ signal })` cuts the wait short. `unbind()` takes no signal, and waits a further
`responseTimeout` for its own response. `responseTimeout` for its own response.
+43 -7
View File
@@ -2,10 +2,12 @@ import type { PduObject } from './pdu.ts';
import type { SmppLog } from './log.ts'; import type { SmppLog } from './log.ts';
import { ExpiringGroups } from './expiring-groups.ts'; import { ExpiringGroups } from './expiring-groups.ts';
import { IdleWaiters } from './idle-waiters.ts'; import { IdleWaiters } from './idle-waiters.ts';
import { retainedOctets } from './retained-pdu.ts';
export type HeldMessagesOptions = { export type HeldMessagesOptions = {
log: SmppLog; log: SmppLog;
max: number; max: number;
maxOctets: number;
/** Injected so expiry can be exercised without a wall clock. */ /** Injected so expiry can be exercised without a wall clock. */
now?: (() => number) | undefined; now?: (() => number) | undefined;
timeout: number; timeout: number;
@@ -18,12 +20,19 @@ function keyOf(pduObjs: PduObject[]): string | undefined {
return first ? String(first.seqNr) : undefined; return first ? String(first.seqNr) : undefined;
} }
type Held = {
octets: number;
pduObjs: PduObject[];
};
/** The messages handed to the application that it has not answered yet, held by their segments. */ /** The messages handed to the application that it has not answered yet, held by their segments. */
export class HeldMessages { export class HeldMessages {
private readonly held: ExpiringGroups<PduObject[]>; private readonly held: ExpiringGroups<Held>;
private readonly idleWaiters = new IdleWaiters(); private readonly idleWaiters = new IdleWaiters();
private readonly log: SmppLog; private readonly log: SmppLog;
private readonly max: number; private readonly max: number;
private readonly maxOctets: number;
private octets = 0;
constructor(options: HeldMessagesOptions) { constructor(options: HeldMessagesOptions) {
this.held = new ExpiringGroups({ this.held = new ExpiringGroups({
@@ -34,6 +43,7 @@ export class HeldMessages {
}); });
this.log = options.log; this.log = options.log;
this.max = options.max; this.max = options.max;
this.maxOctets = options.maxOctets;
} }
get size(): number { get size(): number {
@@ -48,35 +58,49 @@ export class HeldMessages {
this.sweep(); this.sweep();
if (this.held.get(key)) { const replaced = this.held.get(key);
if (replaced) {
this.log.warn('heldMessages - replacing a message on a re-used sequence number', { seqNr: Number(key) }); this.log.warn('heldMessages - replacing a message on a re-used sequence number', { seqNr: Number(key) });
this.delete(key, replaced);
} else if (this.held.full) { } else if (this.held.full) {
this.dropOldest(); this.dropOldest();
} }
this.held.set(key, pduObjs); const octets = pduObjs.reduce((sum, pduObj) => sum + retainedOctets(pduObj), 0);
// The message just held stays even alone past the cap: the peer is still owed its answer.
while (this.held.size > 0 && this.octets + octets > this.maxOctets) {
this.dropOldest();
}
this.held.set(key, { octets, pduObjs });
this.octets += octets;
} }
/** Whether a drain is still waiting for this message to be answered. */ /** Whether a drain is still waiting for this message to be answered. */
has(pduObjs: PduObject[]): boolean { has(pduObjs: PduObject[]): boolean {
const key = keyOf(pduObjs); const key = keyOf(pduObjs);
return key !== undefined && this.held.get(key) === pduObjs; return key !== undefined && this.held.get(key)?.pduObjs === pduObjs;
} }
release(pduObjs: PduObject[]): void { release(pduObjs: PduObject[]): void {
const key = keyOf(pduObjs); const key = keyOf(pduObjs);
// Identity, not the key: a wrapped sequence number must not release someone else's message. // Identity, not the key: a wrapped sequence number must not release someone else's message.
if (key === undefined || this.held.get(key) !== pduObjs) return; const held = key === undefined ? undefined : this.held.get(key);
this.held.delete(key); if (key === undefined || held?.pduObjs !== pduObjs) return;
this.delete(key, held);
this.settle(); this.settle();
} }
/** Drops every message: their segments went with the link, so no answer of ours correlates now. */ /** Drops every message: their segments went with the link, so no answer of ours correlates now. */
clear(): void { clear(): void {
this.held.takeAll(); this.held.takeAll();
this.octets = 0;
this.idleWaiters.settle(); this.idleWaiters.settle();
} }
@@ -90,10 +114,13 @@ export class HeldMessages {
if (!oldest) return; if (!oldest) return;
const [seqNr] = oldest; const [seqNr, held] = oldest;
this.octets -= held.octets;
this.log.warn('heldMessages - buffer full, dropping the oldest message', { this.log.warn('heldMessages - buffer full, dropping the oldest message', {
max: this.max, max: this.max,
maxOctets: this.maxOctets,
octets: this.octets,
seqNr: Number(seqNr), seqNr: Number(seqNr),
}); });
} }
@@ -104,12 +131,21 @@ export class HeldMessages {
if (expired.length === 0) return; if (expired.length === 0) return;
for (const [, held] of expired) {
this.octets -= held.octets;
}
this.log.warn('heldMessages - messages the application never answered', { this.log.warn('heldMessages - messages the application never answered', {
messages: expired.length, messages: expired.length,
}); });
this.settle(); this.settle();
} }
private delete(key: string, held: Held): void {
this.held.delete(key);
this.octets -= held.octets;
}
private settle(): void { private settle(): void {
if (this.held.size === 0) this.idleWaiters.settle(); if (this.held.size === 0) this.idleWaiters.settle();
} }
+3 -1
View File
@@ -13,6 +13,7 @@ import { Reassembler, decodeSegments } from './reassembly.ts';
import { bindCommands, defaults, standsInFor } from './session-options.ts'; import { bindCommands, defaults, standsInFor } from './session-options.ts';
import { concatOf } from './concat.ts'; import { concatOf } from './concat.ts';
import { createSms } from './sms.ts'; import { createSms } from './sms.ts';
import { detach } from './retained-pdu.ts';
import { dlrFromPdu } from './dlr.ts'; import { dlrFromPdu } from './dlr.ts';
import { paramText } from './defs/types.ts'; import { paramText } from './defs/types.ts';
import { respIdParams, segmentId } from './sms-id.ts'; import { respIdParams, segmentId } from './sms-id.ts';
@@ -72,6 +73,7 @@ export class IncomingRequests {
this.held = new HeldMessages({ this.held = new HeldMessages({
log: options.log, log: options.log,
max: defaults.maxHeldMessages, max: defaults.maxHeldMessages,
maxOctets: defaults.maxHeldOctets,
timeout: defaults.heldMessageTimeout, timeout: defaults.heldMessageTimeout,
}); });
this.log = options.log; this.log = options.log;
@@ -213,7 +215,7 @@ export class IncomingRequests {
const concat = concatOf(pduObj); const concat = concatOf(pduObj);
if (!concat) { if (!concat) {
this.emitSms([pduObj]); this.emitSms([detach(pduObj)]);
return; return;
} }
+3 -52
View File
@@ -1,12 +1,11 @@
import type { Concat } from './concat.ts'; import type { Concat } from './concat.ts';
import type { ParamValue } from './defs/types.ts';
import type { PduObject } from './pdu.ts'; import type { PduObject } from './pdu.ts';
import type { SmppLog } from './log.ts'; import type { SmppLog } from './log.ts';
import type { Tlv } from './defs/tlvs.ts';
import { ExpiringGroups } from './expiring-groups.ts'; import { ExpiringGroups } from './expiring-groups.ts';
import { decodeMessage } from './message.ts'; import { decodeMessage } from './message.ts';
import { detach, retainedOctets } from './retained-pdu.ts';
import { messageOctets } from './message-body.ts'; import { messageOctets } from './message-body.ts';
import { detachedTlv, paramNumber, paramText, tlvOctets } from './defs/types.ts'; import { paramNumber, paramText } from './defs/types.ts';
import { uuidv7 } from './uuid.ts'; import { uuidv7 } from './uuid.ts';
/** A concatenated message given up on, whose segments the peer has already been answered for. */ /** A concatenated message given up on, whose segments the peer has already been answered for. */
@@ -52,54 +51,6 @@ type Group = {
total: number; total: number;
}; };
/** Wire reads hand back views, so retaining one segment would pin the whole PDU it arrived in. */
function detach(pduObj: PduObject): PduObject {
const params: Record<string, ParamValue> = {};
const tlvs: Record<string, Tlv> = {};
for (const [name, value] of Object.entries(pduObj.params)) {
params[name] = Buffer.isBuffer(value) ? Buffer.from(value) : value;
}
for (const [name, tlv] of Object.entries(pduObj.tlvs)) {
tlvs[name] = { ...tlv, tagValue: detachedTlv(tlv.tagValue) };
}
// short_message holds the same octets wherever it was not decoded, so one copy covers both.
const octets = Buffer.isBuffer(params.short_message)
? params.short_message
: pduObj.shortMessageOctets && Buffer.from(pduObj.shortMessageOctets);
return { ...pduObj, params, shortMessageOctets: octets, tlvs };
}
// Measured heap beyond the octets, so a segment of empty fields or empty TLVs is not free.
const segmentObjectOverhead = 1000;
const tlvObjectOverhead = 300;
// A cstring param arrives as a string, and source_addr alone can carry most of a 1 MiB PDU.
function sizeOf(value: ParamValue): number {
if (Buffer.isBuffer(value)) return value.length;
return typeof value === 'string' ? value.length : 0;
}
function octetsOf(pduObj: PduObject): number {
let octets = segmentObjectOverhead;
for (const value of Object.values(pduObj.params)) {
octets += sizeOf(value);
}
for (const tlv of Object.values(pduObj.tlvs)) {
const listed = Array.isArray(tlv.tagValue) ? tlv.tagValue.length : 0;
octets += tlvOctets(tlv.tagValue) + (1 + listed) * tlvObjectOverhead;
}
return octets;
}
// NUL: the one octet a C-Octet String address cannot hold, so no sender can forge another's key. // 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 { function groupKey(pduObj: PduObject, concat: Concat): string {
return [ return [
@@ -169,7 +120,7 @@ export class Reassembler {
const group = existing ?? this.open(key, concat.total); const group = existing ?? this.open(key, concat.total);
const replaced = group.parts.get(concat.part); const replaced = group.parts.get(concat.part);
const segment = detach(pduObj); const segment = detach(pduObj);
const delta = octetsOf(segment) - (replaced === undefined ? 0 : octetsOf(replaced)); const delta = retainedOctets(segment) - (replaced === undefined ? 0 : retainedOctets(replaced));
group.parts.set(concat.part, segment); group.parts.set(concat.part, segment);
group.octets += delta; group.octets += delta;
+53
View File
@@ -0,0 +1,53 @@
import type { ParamValue } from './defs/types.ts';
import type { PduObject } from './pdu.ts';
import type { Tlv } from './defs/tlvs.ts';
import { detachedTlv, tlvOctets } from './defs/types.ts';
/** Wire reads hand back views, so retaining one PDU would pin the whole chunk it arrived in. */
export function detach(pduObj: PduObject): PduObject {
const params: Record<string, ParamValue> = {};
const tlvs: Record<string, Tlv> = {};
for (const [name, value] of Object.entries(pduObj.params)) {
params[name] = Buffer.isBuffer(value) ? Buffer.from(value) : value;
}
for (const [name, tlv] of Object.entries(pduObj.tlvs)) {
tlvs[name] = { ...tlv, tagValue: detachedTlv(tlv.tagValue) };
}
// short_message holds the same octets wherever it was not decoded, so one copy covers both.
const octets = Buffer.isBuffer(params.short_message)
? params.short_message
: pduObj.shortMessageOctets && Buffer.from(pduObj.shortMessageOctets);
return { ...pduObj, params, shortMessageOctets: octets, tlvs };
}
// Measured heap beyond the octets, so a PDU of empty fields or empty TLVs is not free.
const pduObjectOverhead = 1000;
const tlvObjectOverhead = 300;
// A cstring param arrives as a string, and source_addr alone can carry most of a 1 MiB PDU.
function sizeOf(value: ParamValue): number {
if (Buffer.isBuffer(value)) return value.length;
return typeof value === 'string' ? value.length : 0;
}
/** Roughly the heap a detached PDU holds. */
export function retainedOctets(pduObj: PduObject): number {
let octets = pduObjectOverhead;
for (const value of Object.values(pduObj.params)) {
octets += sizeOf(value);
}
for (const tlv of Object.values(pduObj.tlvs)) {
const listed = Array.isArray(tlv.tagValue) ? tlv.tagValue.length : 0;
octets += tlvOctets(tlv.tagValue) + (1 + listed) * tlvObjectOverhead;
}
return octets;
}
+1
View File
@@ -128,6 +128,7 @@ export const defaults = {
heldMessageTimeout: 300_000, heldMessageTimeout: 300_000,
maxDlrMerges: 1000, maxDlrMerges: 1000,
maxHeldMessages: 1000, maxHeldMessages: 1000,
maxHeldOctets: defaultMaxOctets,
maxOutstanding: 10, maxOutstanding: 10,
maxReassembly: 1000, maxReassembly: 1000,
reassemblyTimeout: 300_000, reassemblyTimeout: 300_000,
+45 -3
View File
@@ -29,6 +29,7 @@ import { client } from '../src/client.ts';
import { closeAfter, closeListenerAfter } from './teardown.ts'; import { closeAfter, closeListenerAfter } from './teardown.ts';
import { concatOf } from '../src/concat.ts'; import { concatOf } from '../src/concat.ts';
import { consts } from '../src/defs/constants.ts'; import { consts } from '../src/defs/constants.ts';
import { detach } from '../src/retained-pdu.ts';
import { errors } from '../src/defs/errors.ts'; import { errors } from '../src/defs/errors.ts';
import { paramNumber, paramText } from '../src/defs/types.ts'; import { paramNumber, paramText } from '../src/defs/types.ts';
import { server } from '../src/server.ts'; import { server } from '../src/server.ts';
@@ -1483,7 +1484,7 @@ describe('held message bounds', () => {
} }
test('drops the message held longest rather than holding every one', () => { test('drops the message held longest rather than holding every one', () => {
const held = new HeldMessages({ log: silentLog, max: 2, timeout: 10_000 }); const held = new HeldMessages({ log: silentLog, max: 2, maxOctets: 1_000_000, timeout: 10_000 });
const oldest = message(1); const oldest = message(1);
held.hold(oldest); held.hold(oldest);
@@ -1501,9 +1502,50 @@ describe('held message bounds', () => {
held.clear(); held.clear();
}); });
// submitPdu() holds 1026 octets by the maxOctets charge: its object, and the three text fields.
test('drops the message held longest once the octets held pass the cap', () => {
const held = new HeldMessages({ log: silentLog, max: 10, maxOctets: 2100, timeout: 10_000 });
const oldest = message(1);
held.hold(oldest);
held.hold(message(2));
assert.equal(held.size, 2);
held.hold(message(3));
assert.equal(held.size, 2);
assert.equal(held.has(oldest), false);
held.clear();
});
// Dropping it would leave the drain blind to a message the peer is still owed an answer for.
test('keeps a message larger than the cap on its own', () => {
const held = new HeldMessages({ log: silentLog, max: 10, maxOctets: 1000, timeout: 10_000 });
const large = message(2);
held.hold(message(1));
held.hold(large);
assert.equal(held.size, 1);
assert.equal(held.has(large), true);
held.clear();
});
test('holds a message detached from the chunk it was read from', () => {
const chunk = Buffer.alloc(64 * 1024);
const carried = submitPdu(1);
const retained = detach({ ...carried, params: { ...carried.params, short_message: chunk.subarray(16, 20) } });
assert.ok(Buffer.isBuffer(retained.params.short_message));
assert.notEqual(retained.params.short_message.buffer, chunk.buffer);
});
test('gives up on a message the application never answers', () => { test('gives up on a message the application never answers', () => {
let now = 0; let now = 0;
const held = new HeldMessages({ log: silentLog, max: 10, now: () => now, timeout: 60 }); const held = new HeldMessages({ log: silentLog, max: 10, maxOctets: 1_000_000, now: () => now, timeout: 60 });
held.hold(message(1)); held.hold(message(1));
now = 61; now = 61;
@@ -1519,7 +1561,7 @@ describe('held message bounds', () => {
// Without this the drain sits out its whole budget before returning what a sweep already settled. // Without this the drain sits out its whole budget before returning what a sweep already settled.
test('wakes a waiting drain when the last message expires', async () => { test('wakes a waiting drain when the last message expires', async () => {
let now = 0; let now = 0;
const held = new HeldMessages({ log: silentLog, max: 10, now: () => now, timeout: 60 }); const held = new HeldMessages({ log: silentLog, max: 10, maxOctets: 1_000_000, now: () => now, timeout: 60 });
held.hold(message(1)); held.hold(message(1));
-8
View File
@@ -6,14 +6,6 @@ hard rules first — they constrain every item below.
This is a working file that sets its own rules. The documentation conventions in AGENTS.md do not This is a working file that sets its own rules. The documentation conventions in AGENTS.md do not
govern it, and nothing here is a source anything else may cite. govern it, and nothing here is a source anything else may cite.
## Security
- [ ] **Bound the heap `HeldMessages` keeps, not only its count.** It holds up to 1000 messages the
application has not answered for up to 300 s, each as the PDUs it arrived in, undetached and
uncharged: one 200 KB PDU of 50,000 empty unknown TLVs is 15 MB of heap, and a completed
reassembly is up to `maxOctets`. A peer faster than an application answering asynchronously
holds gigabytes per session. From the stability review of #27.
## Status ## Status
The rewrite is **feature complete and green**: the suite, lint and typecheck are clean on Node 18 The rewrite is **feature complete and green**: the suite, lint and typecheck are clean on Node 18