Bound the messages held for a shutdown, and report a partial receipt

This commit is contained in:
2026-09-01 17:42:22 +02:00
parent 1e7e113bd7
commit 67c0402def
11 changed files with 201 additions and 41 deletions
+65 -6
View File
@@ -1,24 +1,68 @@
import type { PduObject } from './pdu.ts';
import type { SmppLog } from './log.ts';
import { ExpiringGroups } from './expiring-groups.ts';
import { IdleWaiters } from './idle-waiters.ts';
export type HeldMessagesOptions = {
log: SmppLog;
max: number;
timeout: number;
};
/** The peer's own sequence number, which is what our answer to this message will carry. */
function keyOf(pduObjs: PduObject[]): string | undefined {
const first = pduObjs[0];
return first ? String(first.seqNr) : undefined;
}
/** The messages handed to the application that it has not answered yet, held by their segments. */
export class HeldMessages {
private readonly held = new Set<PduObject[]>();
private readonly held: ExpiringGroups<PduObject[]>;
private readonly idleWaiters = new IdleWaiters();
private readonly log: SmppLog;
constructor(options: HeldMessagesOptions) {
this.held = new ExpiringGroups({
max: options.max,
onSweep: () => { this.sweep(); },
timeout: options.timeout,
});
this.log = options.log;
}
/** An application that answers no message at all may not grow this without end. */
hold(pduObjs: PduObject[]): void {
this.held.add(pduObjs);
const key = keyOf(pduObjs);
if (key === undefined) return;
if (this.held.full) {
const evicted = this.held.takeOldest();
if (evicted) {
this.log.warn('heldMessages - dropping the message held longest', { seqNr: evicted[0] });
}
}
this.held.set(key, pduObjs);
}
/** Whether a drain is still waiting for this message to be answered. */
has(pduObjs: PduObject[]): boolean {
return this.held.has(pduObjs);
const key = keyOf(pduObjs);
return key !== undefined && this.held.get(key) === pduObjs;
}
release(pduObjs: PduObject[]): void {
if (!this.held.delete(pduObjs)) return;
const key = keyOf(pduObjs);
if (this.held.size === 0) this.idleWaiters.settle();
// Identity, not the key: a wrapped sequence number must not release someone else's message.
if (key === undefined || this.held.get(key) !== pduObjs) return;
this.held.delete(key);
this.settle();
}
/** Drops every message: their segments went with the link, so no answer of ours correlates now. */
@@ -28,7 +72,22 @@ export class HeldMessages {
}
/** Resolves 0 once every message has been answered, or with how many have not. */
idle(timeout: number, signal?: AbortSignal): Promise<number> {
idle(timeout: number, signal: AbortSignal | undefined): Promise<number> {
return this.idleWaiters.wait(() => this.held.size, timeout, signal);
}
private sweep(): void {
const expired = this.held.takeExpired();
if (expired.length === 0) return;
this.log.warn('heldMessages - messages the application never answered', {
messages: expired.length,
});
this.settle();
}
private settle(): void {
if (this.held.size === 0) this.idleWaiters.settle();
}
}
+7 -2
View File
@@ -31,7 +31,7 @@ export type IncomingRequestsOptions = {
/** Everything the peer asks of a session: messages, receipts, links and the answers to them. */
export class IncomingRequests {
private readonly dlrMerger: DlrMerger;
private readonly held = new HeldMessages();
private readonly held: HeldMessages;
private readonly log: SmppLog;
private readonly onRequest: OnRequest | undefined;
private readonly reassembler: Reassembler;
@@ -42,6 +42,11 @@ export class IncomingRequests {
constructor(options: IncomingRequestsOptions) {
this.dlrMerger = options.dlrMerger;
this.held = new HeldMessages({
log: options.log,
max: defaults.maxHeldMessages,
timeout: defaults.heldMessageTimeout,
});
this.log = options.log;
this.onRequest = options.onRequest;
this.reassembler = new Reassembler({
@@ -96,7 +101,7 @@ export class IncomingRequests {
}
/** Waits out the messages the application still holds, and says how many it never answered. */
async drain(timeout: number, signal?: AbortSignal): Promise<VoidResult> {
async drain(timeout: number, signal: AbortSignal | undefined): Promise<VoidResult> {
const unanswered = await this.held.idle(timeout, signal);
if (unanswered === 0) return {};
+1 -1
View File
@@ -35,7 +35,7 @@ export { uuidv7 } from './uuid.ts';
export type { BindType, ClientOptions } from './client.ts';
export type { Dlr, Receipt } from './dlr.ts';
export type { SendRespOptions, Sms, SmsInput } from './sms.ts';
export type { SendDlrResult, SendRespOptions, Sms, SmsInput } from './sms.ts';
export type { ConcatInfo } from './udh.ts';
export type { Result, VoidResult } from './result.ts';
export type { SmppLog } from './log.ts';
+14 -6
View File
@@ -24,6 +24,13 @@ function abortedBeforeSend(): Error {
return new Error('Aborted before the request was sent');
}
/** A response carries the request's sequence number, which only sendReturn() has. */
function misuse(input: PduObjectInput): Error | undefined {
return input.cmdName.endsWith('_resp')
? new Error(`Use sendReturn() for responses, not send(): ${input.cmdName}`)
: undefined;
}
/** Everything this end asks of the peer: which link carries it, how many at once, and the answer. */
export class OutgoingRequests {
private readonly gate: LinkGate;
@@ -67,6 +74,11 @@ export class OutgoingRequests {
/** Sends a request and resolves with the peer's response. */
request(input: PduObjectInput, options: SendOptions): Promise<Result<{ pduObj: PduObject }>> {
// Ahead of the drain, so a misuse is named as one rather than blamed on the shutdown.
const wrong = misuse(input);
if (wrong) return Promise.resolve({ err: wrong });
// A drain on a live link. A link that is down is the gate's answer, which says closed instead.
if (this.draining && !this.linkDown()) {
return Promise.resolve({ err: new Error('Session is shutting down') });
@@ -123,19 +135,15 @@ export class OutgoingRequests {
if (unfinished === 0) return {};
this.log.warn('session - shutting down with requests unfinished', { timeout, unfinished });
this.log.warn('outgoingRequests - shutting down with requests unfinished', { timeout, unfinished });
return { err: new Error(`Shut down with ${String(unfinished)} request(s) unfinished`) };
}
/** Why a request cannot go out at all, as opposed to not yet. */
private refuse(input: PduObjectInput, options: SendOptions): Error | undefined {
if (input.cmdName.endsWith('_resp')) {
return new Error(`Use sendReturn() for responses, not send(): ${input.cmdName}`);
}
// Before the gate and the window, or an aborted call waits for what it will never use.
return options.signal?.aborted === true ? abortedBeforeSend() : undefined;
return misuse(input) ?? (options.signal?.aborted === true ? abortedBeforeSend() : undefined);
}
private async attempt(input: PduObjectInput, options: SendOptions): Promise<Attempt> {
+3
View File
@@ -101,8 +101,11 @@ export const undeclaredInterfaceVersion = 0x00;
export const defaults = {
/** Receipts of a multipart message can be a working day apart, so the cap does the bounding. */
dlrMergeTimeout: 86_400_000,
/** The peer gave up on an unanswered message long before this; the bound is against growth. */
heldMessageTimeout: 300_000,
maxDelay: 30_000,
maxDlrMerges: 1000,
maxHeldMessages: 1000,
maxOutstanding: 10,
maxReassembly: 1000,
minDelay: 1000,
+6 -6
View File
@@ -313,14 +313,14 @@ export class Session extends EventEmitter<SessionEvents> {
return { err: new Error('The session closed before the drain finished') };
}
return messages.err ? messages : requests;
if (!messages.err) return requests;
if (!requests.err) return messages;
return { err: new Error(`${messages.err.message}; ${requests.err.message}`) };
}
/**
* How long the drain waits for the application, which is the only thing that can end that wait.
* Neither timeout may hand it "forever": both are answers about a peer, and a peer is not what
* this half is waiting for.
*/
/** The application half's budget, which may never be "forever": nothing else ends that wait. */
private answering(timeout: number): number {
if (timeout > 0) return timeout;
+25 -6
View File
@@ -3,11 +3,20 @@ import type { MessageState } from './defs/constants.ts';
import type { PduObject, PduObjectInput, TlvInput } from './pdu.ts';
import type { Result, VoidResult } from './result.ts';
import type { Session } from './session.ts';
import { UnansweredError } from './unanswered-error.ts';
import { consts } from './defs/constants.ts';
import { receiptCodes } from './dlr.ts';
import { smppDate } from './message.ts';
import { uuidv7 } from './uuid.ts';
/** Both fields hold what the peer took, so a partial failure names what is already receipted. */
export type SendDlrResult = {
err?: Error;
pduObjs: PduObject[];
/** Segments that went out unanswered. The peer may have taken them, so sending again may duplicate. */
unanswered: number;
};
export type SendRespOptions = {
/** The id the peer correlates a later delivery receipt by. Defaults to a generated UUID v7. */
smsId?: string;
@@ -25,7 +34,7 @@ export type Sms = {
message: string;
pduObjs: PduObject[];
/** Sends a delivery report back to the sender. Defaults to DELIVERED. */
sendDlr: (status?: MessageState) => Promise<Result<{ pduObjs: PduObject[] }>>;
sendDlr: (status?: MessageState) => Promise<SendDlrResult>;
/** Answers every segment. Part of the protocol, not optional. Defaults to ESME_ROK. */
sendResp: (options?: SendRespOptions) => Promise<VoidResult>;
session: Session;
@@ -132,9 +141,13 @@ async function sendDlr(
sms: Sms,
send: SmsHandlers['send'],
status: MessageState = 'DELIVERED',
): Promise<Result<{ pduObjs: PduObject[] }>> {
): Promise<SendDlrResult> {
if (!sms.session.bindAllows('deliver_sm')) {
return { err: new Error('A transmitter-bound session does not carry deliver_sm') };
return {
err: new Error('A transmitter-bound session does not carry deliver_sm'),
pduObjs: [],
unanswered: 0,
};
}
const total = sms.pduObjs.length;
@@ -154,12 +167,18 @@ async function sendDlr(
});
}));
const pduObjs: PduObject[] = [];
let failure: Error | undefined;
let unanswered = 0;
for (const one of sent) {
if (one.err) return { err: one.err };
if (!one.err) {
pduObjs.push(one.pduObj);
} else {
if (one.err instanceof UnansweredError) unanswered++;
pduObjs.push(one.pduObj);
failure ??= one.err;
}
}
return { pduObjs };
return failure ? { err: failure, pduObjs, unanswered } : { pduObjs, unanswered };
}