Wait out the messages the application holds before shutting a session down

This commit is contained in:
2026-09-01 13:58:28 +02:00
parent a99e1227ac
commit 3bcea108e3
12 changed files with 263 additions and 79 deletions
+16 -1
View File
@@ -72,6 +72,8 @@ src/
dlr-merger.ts DlrMerger: per-segment receipts counted into one MessageDlr dlr-merger.ts DlrMerger: per-segment receipts counted into one MessageDlr
error-from.ts errorFrom(): whatever was thrown or rejected, as an Error error-from.ts errorFrom(): whatever was thrown or rejected, as an Error
expiring-groups.ts ExpiringGroups: the capped, expiring store both of those share expiring-groups.ts ExpiringGroups: the capped, expiring store both of those share
held-messages.ts HeldMessages: the messages handed to the application and not yet answered
idle-waiters.ts IdleWaiters: waiting for a count to fall to zero, and what is left of a budget
incoming-requests.ts Every request the peer sends: messages, receipts, links, unknown commands incoming-requests.ts Every request the peer sends: messages, receipts, links, unknown commands
link-gate.ts LinkGate: where a request with no link to go out on waits for the next one link-gate.ts LinkGate: where a request with no link to go out on waits for the next one
link-timers.ts LinkTimers: the enquire_link heartbeat and the idle timeout link-timers.ts LinkTimers: the enquire_link heartbeat and the idle timeout
@@ -88,7 +90,7 @@ src/
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
sms-id.ts The notation a peer writes message ids in, normalised for comparison sms-id.ts The notation a peer writes message ids in, normalised for comparison
udh.ts User data header: the concatenation fields of a long SMS udh.ts User data header: the concatenation fields of a long SMS, and their reference
uuid.ts uuidv7() — the ids the library generates for messages uuid.ts uuidv7() — the ids the library generates for messages
defs/ defs/
commands.ts The 33 commands, their ids and ordered parameter lists commands.ts The 33 commands, their ids and ordered parameter lists
@@ -331,6 +333,19 @@ Grouped by what each one constrains.
reports each session's unfinished drain through `serverError`, because its own result says nothing reports each session's unfinished drain through `serverError`, because its own result says nothing
but that the listener stopped. but that the listener stopped.
- **The drain waits on the messages the application holds, and `sendResp()` is what says it is done
with one.** Maintainer's call, 2026-09-01: waiting on the send window alone tore a server session
down while the application was still answering a `submit_sm`, so the peer timed out and re-sent —
the duplicate goal 2 forbids, in the direction the window already covers. No completion signal was
added to the `sms` event: `sendResp()` is the answer the peer is waiting for, so it is the one the
drain waits for. Counting every inbound request until `sendReturn()` answered it was rejected —
an `onRequest` that deliberately answers nothing would then cost a full `shutdownTimeout` on every
close — and a message no listener took is released at once, since nothing is going to answer it.
`teardown()` drops what is still held for the same reason it drops inbound segments. The release
is one turn late, so a listener that sends its receipt straight after the response is still
holding when the drain looks; `sendDlr()` is the one send that goes out past the drain's refusal,
being part of answering a message the drain is itself waiting for.
- **A reconnect keeps the delivery-receipt merges; everything else the link held is dropped.** - **A reconnect keeps the delivery-receipt merges; everything else the link held is dropped.**
`onDeliverSm()` answers each receipt before the group it belongs to is complete, and `teardown()` `onDeliverSm()` answers each receipt before the group it belongs to is complete, and `teardown()`
runs on every path — an idle timeout and a failed rebind, not only `close()` — so clearing the runs on every path — an idle timeout and a failed rebind, not only `close()` — so clearing the
+6 -4
View File
@@ -21,7 +21,7 @@ by hand on top of a library; it is built in here.
| **Submit window** | `maxOutstanding` holds requests in flight at 10; further sends queue instead of overrunning the SMSC. | | **Submit window** | `maxOutstanding` holds requests in flight at 10; further sends queue instead of overrunning the SMSC. |
| **Delivery receipts** | Correlated by `receipted_message_id`/`message_state` where the SMSC sends them, falling back to parsing the receipt text — what Kannel and several others send. | | **Delivery receipts** | Correlated by `receipted_message_id`/`message_state` where the SMSC sends them, falling back to parsing the receipt text — what Kannel and several others send. |
| **Multipart** | Long messages split on send; concatenated `deliver_sm` reassembled into one `sms`. | | **Multipart** | Long messages split on send; concatenated `deliver_sm` reassembled into one `sms`. |
| **Graceful shutdown** | `close()` and `unbind()` wait out the requests this end already sent, so a submit the SMSC accepted is not reported as a failure. | | **Graceful shutdown** | `close()` and `unbind()` wait out the requests this end already sent and the messages the application has not answered yet, so neither end has to guess whether a message got through. |
| **Never throws** | Everything fallible resolves to `{ err?, … }`, the codec included. | | **Never throws** | Everything fallible resolves to `{ err?, … }`, the codec included. |
Throughput throttling is deliberately absent: an operator's rate limit is scoped to the account, and Throughput throttling is deliberately absent: an operator's rate limit is scoped to the account, and
@@ -101,7 +101,7 @@ Every one is optional.
| `enquireLinkInterval` | `20000` | How often to send `enquire_link` on a quiet link. | | `enquireLinkInterval` | `20000` | How often to send `enquire_link` on a quiet link. |
| `idleTimeout` | `2 × enquireLinkInterval` | Give up on a link the peer has stopped answering, and re-bind unless `reconnect` is `false`. | | `idleTimeout` | `2 × enquireLinkInterval` | Give up on a link the peer has stopped answering, and re-bind unless `reconnect` is `false`. |
| `responseTimeout` | `30000` | How long to wait for a response before giving up on it, and how long a send with no link waits for the next one; `0` waits forever. | | `responseTimeout` | `30000` | How long to wait for a response before giving up on it, and how long a send with no link waits for the next one; `0` waits forever. |
| `shutdownTimeout` | `5000` | How long `close()` and `unbind()` wait for the requests this end already sent; `0` waits forever. | | `shutdownTimeout` | `5000` | How long `close()` and `unbind()` wait for the requests this end already sent and the messages the application has not answered; `0` waits forever. |
| `maxOutstanding` | `10` | Requests allowed on the wire at once; further sends queue. | | `maxOutstanding` | `10` | Requests allowed on the wire at once; further sends queue. |
| `smsIdFormat` | — | The notation the SMSC writes message ids in, per place it writes them: `{ receipt: 'decimal', submitResp: 'hex' }`. Only needed where the two disagree. | | `smsIdFormat` | — | The notation the SMSC writes message ids in, per place it writes them: `{ receipt: 'decimal', submitResp: 'hex' }`. Only needed where the two disagree. |
| `reconnect` | on | Re-binds after a drop, an idle timeout, or a stream the library cannot read, backing off from `minDelay` 1 s to `maxDelay` 30 s and starting over at `minDelay` once a link has lasted `maxDelay`. `{ minDelay, maxDelay }` retunes it; `false` turns it off, so a drop ends the session. | | `reconnect` | on | Re-binds after a drop, an idle timeout, or a stream the library cannot read, backing off from `minDelay` 1 s to `maxDelay` 30 s and starting over at `minDelay` once a link has lasted `maxDelay`. `{ minDelay, maxDelay }` retunes it; `false` turns it off, so a drop ends the session. |
@@ -331,8 +331,10 @@ TypeScript users can import `SmppLog` to have the compiler check one.
`sendSms()`, `send()`, `sendReturn()`, `unbind()` and `close()`. Both `close()` and `unbind()` `sendSms()`, `send()`, `sendReturn()`, `unbind()` and `close()`. Both `close()` and `unbind()`
refuse further sends, wait out the requests this end already sent for up to `shutdownTimeout`, and refuse further sends, wait out the requests this end already sent for up to `shutdownTimeout`, and
then tear down whatever is left, resolving to an `err` that says what was lost. A request the peer then tear down whatever is left, resolving to an `err` that says what was lost. They wait on the
sent *us* is answered through `sendReturn()` and is not waited for. `close({ signal })` takes an `sms` events the application has not answered yet too, so a peer whose `submit_sm` is still being
handled is answered rather than left to re-send it; `sendDlr()` goes out during that wait, and every
other send is refused. `close({ signal })` takes an
`AbortSignal` that cuts the wait short; `unbind()` takes none, and waits a further `AbortSignal` that cuts the wait short; `unbind()` takes none, and waits a further
`responseTimeout` for its own response. `send()` reaches any of the 33 SMPP commands the codec `responseTimeout` for its own response. `send()` reaches any of the 33 SMPP commands the codec
knows, not just the four the session handles natively: knows, not just the four the session handles natively:
+29
View File
@@ -0,0 +1,29 @@
import type { PduObject } from './pdu.ts';
import { IdleWaiters } from './idle-waiters.ts';
/** 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 idleWaiters = new IdleWaiters();
hold(pduObjs: PduObject[]): void {
this.held.add(pduObjs);
}
release(pduObjs: PduObject[]): void {
if (!this.held.delete(pduObjs)) return;
if (this.held.size === 0) this.idleWaiters.settle();
}
/** Drops every message: their segments went with the link, so no answer of ours correlates now. */
clear(): void {
this.held.clear();
this.idleWaiters.settle();
}
/** Resolves 0 once every message has been answered, or with how many have not. */
idle(timeout: number, signal?: AbortSignal): Promise<number> {
return this.idleWaiters.wait(() => this.held.size, timeout, signal);
}
}
+47
View File
@@ -0,0 +1,47 @@
/** What is left of a budget, in the shape a wait takes it: 0 waits forever. */
export function leftOf(deadline: number): number {
return deadline === 0 ? 0 : Math.max(1, deadline - Date.now());
}
/** Everything waiting for a count to fall to zero, and how such a wait is cut short. */
export class IdleWaiters {
private readonly waiting: (() => void)[] = [];
/** Wakes everything waiting, whatever the count reads now. */
settle(): void {
for (const resolve of this.waiting.splice(0)) {
resolve();
}
}
/**
* Resolves 0 once nothing is left, or with what still is when the timeout or the signal cuts the
* wait short. A timeout of 0 waits forever.
*/
wait(remaining: () => number, timeout: number, signal?: AbortSignal): Promise<number> {
if (remaining() === 0) return Promise.resolve(0);
if (signal?.aborted === true) return Promise.resolve(remaining());
return new Promise<number>(resolve => {
let timer: NodeJS.Timeout | undefined = undefined;
const done = (): void => {
const index = this.waiting.indexOf(done);
if (timer) clearTimeout(timer);
if (index !== -1) this.waiting.splice(index, 1);
signal?.removeEventListener('abort', done);
resolve(remaining());
};
if (timeout > 0) {
timer = setTimeout(done, timeout);
timer.unref();
}
signal?.addEventListener('abort', done, { once: true });
this.waiting.push(done);
});
}
}
+32 -4
View File
@@ -1,9 +1,11 @@
import type { DlrMerger } from './dlr-merger.ts'; import type { DlrMerger } from './dlr-merger.ts';
import type { OnRequest } from './session-options.ts'; import type { OnRequest } from './session-options.ts';
import type { PduObject } from './pdu.ts'; import type { PduObject, PduObjectInput } from './pdu.ts';
import type { Result, VoidResult } from './result.ts';
import type { Session } from './session.ts'; import type { Session } from './session.ts';
import type { SmppLog } from './log.ts'; import type { SmppLog } from './log.ts';
import type { SmsIdFormat } from './sms-id.ts'; import type { SmsIdFormat } from './sms-id.ts';
import { HeldMessages } from './held-messages.ts';
import { Reassembler, decodeSegments } from './reassembly.ts'; import { Reassembler, decodeSegments } from './reassembly.ts';
import { bindCommands, defaults } from './session-options.ts'; import { bindCommands, defaults } from './session-options.ts';
import { concatInfo } from './udh.ts'; import { concatInfo } from './udh.ts';
@@ -19,6 +21,8 @@ export type IncomingRequestsOptions = {
maxReassembly?: number | undefined; maxReassembly?: number | undefined;
onRequest?: OnRequest | undefined; onRequest?: OnRequest | undefined;
reassemblyTimeout?: number | undefined; reassemblyTimeout?: number | undefined;
/** Past the drain gate: a receipt answering a message the shutdown is still waiting for. */
sendHeld: (input: PduObjectInput) => Promise<Result<{ pduObj: PduObject }>>;
session: Session; session: Session;
smsIdFormat?: SmsIdFormat | undefined; smsIdFormat?: SmsIdFormat | undefined;
systemId?: string | undefined; systemId?: string | undefined;
@@ -27,9 +31,11 @@ export type IncomingRequestsOptions = {
/** Everything the peer asks of a session: messages, receipts, links and the answers to them. */ /** Everything the peer asks of a session: messages, receipts, links and the answers to them. */
export class IncomingRequests { export class IncomingRequests {
private readonly dlrMerger: DlrMerger; private readonly dlrMerger: DlrMerger;
private readonly held = new HeldMessages();
private readonly log: SmppLog; private readonly log: SmppLog;
private readonly onRequest: OnRequest | undefined; private readonly onRequest: OnRequest | undefined;
private readonly reassembler: Reassembler; private readonly reassembler: Reassembler;
private readonly sendHeld: IncomingRequestsOptions['sendHeld'];
private readonly session: Session; private readonly session: Session;
private readonly smsIdFormat: SmsIdFormat; private readonly smsIdFormat: SmsIdFormat;
private readonly systemId: string; private readonly systemId: string;
@@ -44,6 +50,7 @@ export class IncomingRequests {
maxOctets: options.maxOctets, maxOctets: options.maxOctets,
timeout: options.reassemblyTimeout ?? defaults.reassemblyTimeout, timeout: options.reassemblyTimeout ?? defaults.reassemblyTimeout,
}); });
this.sendHeld = options.sendHeld;
this.session = options.session; this.session = options.session;
this.smsIdFormat = options.smsIdFormat ?? {}; this.smsIdFormat = options.smsIdFormat ?? {};
this.systemId = options.systemId ?? defaults.systemId; this.systemId = options.systemId ?? defaults.systemId;
@@ -82,11 +89,23 @@ export class IncomingRequests {
} }
} }
/** Drops the segments of every message that never became whole. */ /** Drops the segments of every message that never became whole, and of every one still held. */
clear(): void { clear(): void {
this.held.clear();
this.reassembler.clear(); this.reassembler.clear();
} }
/** Waits out the messages the application still holds, and says how many it never answered. */
async drain(timeout: number, signal?: AbortSignal): Promise<VoidResult> {
const unanswered = await this.held.idle(timeout, signal);
if (unanswered === 0) return {};
this.log.warn('session - shutting down with messages unanswered', { timeout, unanswered });
return { err: new Error(`Shut down with ${String(unanswered)} message(s) unanswered`) };
}
private async unhandled(pduObj: PduObject): Promise<void> { private async unhandled(pduObj: PduObject): Promise<void> {
if (bindCommands.includes(pduObj.cmdName)) { if (bindCommands.includes(pduObj.cmdName)) {
this.log.info('session - bind on an already bound session', { cmdName: pduObj.cmdName }); this.log.info('session - bind on an already bound session', { cmdName: pduObj.cmdName });
@@ -139,12 +158,21 @@ export class IncomingRequests {
if (!first) return; if (!first) return;
this.session.emit('sms', createSms({ const sms = createSms({
from: paramText(first.params.source_addr), from: paramText(first.params.source_addr),
message: decodeSegments(pduObjs), message: decodeSegments(pduObjs),
pduObjs, pduObjs,
session: this.session, session: this.session,
to: paramText(first.params.destination_addr), to: paramText(first.params.destination_addr),
})); }, {
// A turn later, so a listener sending its receipt straight after the response still holds.
onAnswered: () => { setImmediate(() => { this.held.release(pduObjs); }); },
send: this.sendHeld,
});
this.held.hold(pduObjs);
// A message nobody took is not work a shutdown can wait for.
if (!this.session.emit('sms', sms)) this.held.release(pduObjs);
} }
} }
+6 -1
View File
@@ -37,6 +37,11 @@ export type SendSmsResult = {
unanswered: number; unanswered: number;
}; };
/** A message that never went out, in the shape a caller aggregating segments still reads. */
export function unsent(err: Error): SendSmsResult {
return { err, pduObjs: [], smsIds: [], unanswered: 0 };
}
/** What sending needs from the session: a concat reference and a way onto the wire. */ /** What sending needs from the session: a concat reference and a way onto the wire. */
export type SendSmsDeps = { export type SendSmsDeps = {
log: SmppLog; log: SmppLog;
@@ -141,7 +146,7 @@ export async function submitSms(deps: SendSmsDeps, sms: SendSmsOptions): Promise
const segments = splitMessage(sms.message, { encoding, reference: deps.reference }); const segments = splitMessage(sms.message, { encoding, reference: deps.reference });
const refused = checkSegments(allowed, segments.length); const refused = checkSegments(allowed, segments.length);
if (refused) return { err: refused, pduObjs: [], smsIds: [], unanswered: 0 }; if (refused) return unsent(refused);
const multipart = segments.length > 1; const multipart = segments.length > 1;
+6 -32
View File
@@ -1,8 +1,10 @@
import { IdleWaiters } from './idle-waiters.ts';
/** Caps how many requests are on the wire at once; anything past the limit waits its turn. */ /** Caps how many requests are on the wire at once; anything past the limit waits its turn. */
export class SendWindow { export class SendWindow {
private readonly idleWaiters = new IdleWaiters();
private readonly limit: number; private readonly limit: number;
private readonly waiting: (() => void)[] = []; private readonly waiting: (() => void)[] = [];
private readonly waitingForIdle: (() => void)[] = [];
private inFlight = 0; private inFlight = 0;
constructor(limit: number) { constructor(limit: number) {
@@ -32,9 +34,7 @@ export class SendWindow {
if (this.inFlight > 0) return; if (this.inFlight > 0) return;
for (const resolve of this.waitingForIdle.splice(0)) { this.idleWaiters.settle();
resolve();
}
} }
/** Everything the caller is still owed: on the wire, plus queued behind a full window. */ /** Everything the caller is still owed: on the wire, plus queued behind a full window. */
@@ -42,34 +42,8 @@ export class SendWindow {
return this.inFlight + this.waiting.length; return this.inFlight + this.waiting.length;
} }
/** /** Resolves 0 once nothing is left on the wire, or with what still is. */
* Resolves 0 once nothing is left, or with what still is when the timeout or the signal cuts the
* wait short. A timeout of 0 waits forever.
*/
idle(timeout: number, signal?: AbortSignal): Promise<number> { idle(timeout: number, signal?: AbortSignal): Promise<number> {
if (this.inFlight === 0) return Promise.resolve(0); return this.idleWaiters.wait(() => this.unfinished(), timeout, signal);
if (signal?.aborted === true) return Promise.resolve(this.unfinished());
return new Promise<number>(resolve => {
let timer: NodeJS.Timeout | undefined = undefined;
const done = (): void => {
const index = this.waitingForIdle.indexOf(done);
if (timer) clearTimeout(timer);
if (index !== -1) this.waitingForIdle.splice(index, 1);
signal?.removeEventListener('abort', done);
resolve(this.unfinished());
};
if (timeout > 0) {
timer = setTimeout(done, timeout);
timer.unref();
}
signal?.addEventListener('abort', done, { once: true });
this.waitingForIdle.push(done);
});
} }
} }
+23 -21
View File
@@ -16,12 +16,14 @@ import { PduTransport } from './pdu-transport.ts';
import { PendingRequests, UnansweredError } from './pending-requests.ts'; import { PendingRequests, UnansweredError } from './pending-requests.ts';
import { ReconnectLoop } from './reconnect-loop.ts'; import { ReconnectLoop } from './reconnect-loop.ts';
import { SendWindow } from './send-window.ts'; import { SendWindow } from './send-window.ts';
import { leftOf } from './idle-waiters.ts';
import { errorFrom } from './error-from.ts'; import { errorFrom } from './error-from.ts';
import { optionalParamsMinVersion } from './defs/constants.ts'; import { optionalParamsMinVersion } from './defs/constants.ts';
import { bindCarries, bindCommands, defaultSystemId, defaults } from './session-options.ts'; import { bindCarries, bindCommands, defaultSystemId, defaults } from './session-options.ts';
import { isResp, objToPdu, pduReturn } from './pdu.ts'; import { isResp, objToPdu, pduReturn } from './pdu.ts';
import { silentLog } from './log.ts'; import { silentLog } from './log.ts';
import { submitSms } from './send-sms.ts'; import { submitSms, unsent } from './send-sms.ts';
import { ConcatReference } from './udh.ts';
export type { export type {
CloseOptions, CloseOptions,
@@ -64,6 +66,7 @@ export class Session extends EventEmitter<SessionEvents> {
peerInterfaceVersion: number | undefined = undefined; peerInterfaceVersion: number | undefined = undefined;
userData: unknown = undefined; userData: unknown = undefined;
private readonly concatReference = new ConcatReference();
private readonly dlrMerger: DlrMerger; private readonly dlrMerger: DlrMerger;
private readonly gate: LinkGate; private readonly gate: LinkGate;
private readonly incoming: IncomingRequests; private readonly incoming: IncomingRequests;
@@ -75,7 +78,6 @@ export class Session extends EventEmitter<SessionEvents> {
private readonly window: SendWindow; private readonly window: SendWindow;
private closed = false; private closed = false;
private concatReference = 0;
private draining = false; private draining = false;
private ended = false; private ended = false;
@@ -129,6 +131,7 @@ export class Session extends EventEmitter<SessionEvents> {
maxReassembly: options.maxReassembly, maxReassembly: options.maxReassembly,
onRequest: options.onRequest, onRequest: options.onRequest,
reassemblyTimeout: options.reassemblyTimeout, reassemblyTimeout: options.reassemblyTimeout,
sendHeld: input => this.sendThrough(input, {}),
session: this, session: this,
smsIdFormat: options.smsIdFormat, smsIdFormat: options.smsIdFormat,
systemId: options.systemId, systemId: options.systemId,
@@ -166,7 +169,15 @@ export class Session extends EventEmitter<SessionEvents> {
} }
/** Sends a request and resolves with the peer's response. */ /** Sends a request and resolves with the peer's response. */
async send( send(input: PduObjectInput, options: SendOptions = {}): Promise<Result<{ pduObj: PduObject }>> {
// 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') });
return this.sendThrough(input, options);
}
/** The same path without that refusal, which a receipt for a held message has to take. */
private async sendThrough(
input: PduObjectInput, input: PduObjectInput,
options: SendOptions = {}, options: SendOptions = {},
): Promise<Result<{ pduObj: PduObject }>> { ): Promise<Result<{ pduObj: PduObject }>> {
@@ -199,9 +210,6 @@ export class Session extends EventEmitter<SessionEvents> {
return new Error(`Use sendReturn() for responses, not send(): ${input.cmdName}`); return new Error(`Use sendReturn() for responses, not send(): ${input.cmdName}`);
} }
// 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 new Error('Session is shutting down');
// Before the gate and the window, or an aborted call waits for what it will never use. // Before the gate and the window, or an aborted call waits for what it will never use.
if (options.signal?.aborted === true) return abortedBeforeSend(); if (options.signal?.aborted === true) return abortedBeforeSend();
@@ -239,17 +247,12 @@ export class Session extends EventEmitter<SessionEvents> {
async sendSms(sms: SendSmsOptions, options: SendOptions = {}): Promise<SendSmsResult> { async sendSms(sms: SendSmsOptions, options: SendOptions = {}): Promise<SendSmsResult> {
if (!this.bindAllows('submit_sm')) { if (!this.bindAllows('submit_sm')) {
return { return unsent(new Error('A receiver-bound session does not carry submit_sm'));
err: new Error('A receiver-bound session does not carry submit_sm'),
pduObjs: [],
smsIds: [],
unanswered: 0,
};
} }
const sent = await submitSms({ const sent = await submitSms({
log: this.log, log: this.log,
reference: this.nextConcatReference(), reference: this.concatReference.next(),
respIdNotation: this.options.smsIdFormat?.submitResp, respIdNotation: this.options.smsIdFormat?.submitResp,
send: input => this.send(input, options), send: input => this.send(input, options),
}, sms); }, sms);
@@ -379,7 +382,7 @@ export class Session extends EventEmitter<SessionEvents> {
return { result: answered.err ? { err: new UnansweredError(answered.err) } : answered, retryOnNextLink: false }; return { result: answered.err ? { err: new UnansweredError(answered.err) } : answered, retryOnNextLink: false };
} }
/** Stops new sends and waits out the ones already issued. */ /** Stops new sends and waits out the messages we hold and the requests already issued. */
private async drain(signal: AbortSignal | undefined): Promise<VoidResult> { private async drain(signal: AbortSignal | undefined): Promise<VoidResult> {
this.reconnectLoop?.stop(); this.reconnectLoop?.stop();
this.draining = true; this.draining = true;
@@ -387,11 +390,16 @@ export class Session extends EventEmitter<SessionEvents> {
if (this.linkDown()) return {}; if (this.linkDown()) return {};
const timeout = this.options.shutdownTimeout ?? defaults.shutdownTimeout; const timeout = this.options.shutdownTimeout ?? defaults.shutdownTimeout;
const unfinished = await this.window.idle(timeout, signal); const deadline = timeout > 0 ? Date.now() + timeout : 0;
// Answering a message can put a receipt on the wire; nothing on the wire produces a message.
const messages = await this.incoming.drain(timeout, signal);
const unfinished = await this.window.idle(leftOf(deadline), signal);
// The window empties on a teardown too, which settles everything the link was carrying. // The window empties on a teardown too, which settles everything the link was carrying.
if (this.linkDown()) return { err: new Error('The session closed before the drain finished') }; if (this.linkDown()) return { err: new Error('The session closed before the drain finished') };
if (messages.err) return messages;
if (unfinished === 0) return {}; if (unfinished === 0) return {};
this.log.warn('session - shutting down with requests unfinished', { timeout, unfinished }); this.log.warn('session - shutting down with requests unfinished', { timeout, unfinished });
@@ -433,12 +441,6 @@ export class Session extends EventEmitter<SessionEvents> {
return this.reconnectLoop !== undefined && !this.reconnectLoop.isStopped(); return this.reconnectLoop !== undefined && !this.reconnectLoop.isStopped();
} }
private nextConcatReference(): number {
this.concatReference = this.concatReference >= 255 ? 1 : this.concatReference + 1;
return this.concatReference;
}
private onData(chunk: Buffer): void { private onData(chunk: Buffer): void {
this.emit('data', chunk); this.emit('data', chunk);
this.resetTimers(); this.resetTimers();
+12 -5
View File
@@ -1,6 +1,6 @@
import type { ErrorName } from './defs/errors.ts'; import type { ErrorName } from './defs/errors.ts';
import type { MessageState } from './defs/constants.ts'; import type { MessageState } from './defs/constants.ts';
import type { PduObject, TlvInput } from './pdu.ts'; import type { PduObject, PduObjectInput, TlvInput } from './pdu.ts';
import type { Result, VoidResult } from './result.ts'; import type { Result, VoidResult } from './result.ts';
import type { Session } from './session.ts'; import type { Session } from './session.ts';
import { consts } from './defs/constants.ts'; import { consts } from './defs/constants.ts';
@@ -43,12 +43,18 @@ export type SmsInput = {
to: string; to: string;
}; };
/** What the session's incoming side gives a message so it can be answered and accounted for. */
export type SmsHandlers = {
onAnswered: () => void;
send: (input: PduObjectInput) => Promise<Result<{ pduObj: PduObject }>>;
};
/** Each segment of a multipart message gets its own message_id, as a separate submit_sm must. */ /** Each segment of a multipart message gets its own message_id, as a separate submit_sm must. */
function segmentId(smsId: string, index: number, total: number): string { function segmentId(smsId: string, index: number, total: number): string {
return total === 1 ? smsId : `${smsId}-${String(index + 1)}`; return total === 1 ? smsId : `${smsId}-${String(index + 1)}`;
} }
export function createSms(input: SmsInput): Sms { export function createSms(input: SmsInput, handlers: SmsHandlers): Sms {
const first = input.pduObjs[0]; const first = input.pduObjs[0];
const registered = first?.params.registered_delivery; const registered = first?.params.registered_delivery;
const dataCoding = first?.params.data_coding; const dataCoding = first?.params.data_coding;
@@ -60,8 +66,8 @@ export function createSms(input: SmsInput): Sms {
from: input.from, from: input.from,
message: input.message, message: input.message,
pduObjs: input.pduObjs, pduObjs: input.pduObjs,
sendDlr: status => sendDlr(sms, status), sendDlr: status => sendDlr(sms, handlers.send, status),
sendResp: options => sendResp(sms, answered, options ?? {}), sendResp: options => sendResp(sms, answered, options ?? {}).finally(handlers.onAnswered),
session: input.session, session: input.session,
get smsId(): string { get smsId(): string {
return answered.smsId; return answered.smsId;
@@ -124,6 +130,7 @@ function receiptTlvs(smsId: string, status: MessageState): Record<string, TlvInp
async function sendDlr( async function sendDlr(
sms: Sms, sms: Sms,
send: SmsHandlers['send'],
status: MessageState = 'DELIVERED', status: MessageState = 'DELIVERED',
): Promise<Result<{ pduObjs: PduObject[] }>> { ): Promise<Result<{ pduObjs: PduObject[] }>> {
if (!sms.session.bindAllows('deliver_sm')) { if (!sms.session.bindAllows('deliver_sm')) {
@@ -135,7 +142,7 @@ async function sendDlr(
for (let index = 0; index < total; index++) { for (let index = 0; index < total; index++) {
const smsId = segmentId(sms.smsId, index, total); const smsId = segmentId(sms.smsId, index, total);
const sent = await sms.session.send({ const sent = await send({
cmdName: 'deliver_sm', cmdName: 'deliver_sm',
params: { params: {
destination_addr: sms.from, destination_addr: sms.from,
+11
View File
@@ -1,3 +1,14 @@
/** The 8-bit reference tying a long SMS's segments together, counted per session. */
export class ConcatReference {
private current = 0;
next(): number {
this.current = this.current >= 255 ? 1 : this.current + 1;
return this.current;
}
}
export type ConcatInfo = { export type ConcatInfo = {
part: number; part: number;
reference: number; reference: number;
+69 -2
View File
@@ -1037,8 +1037,12 @@ describe('AbortSignal on a send', () => {
}); });
describe('graceful shutdown', () => { describe('graceful shutdown', () => {
async function submitInFlight(t: TestContext, options: Parameters<typeof client>[0] = {}) { async function submitInFlight(
const smpp = await startServer(t); t: TestContext,
options: Parameters<typeof client>[0] = {},
serverOptions: Parameters<typeof server>[0] = {},
) {
const smpp = await startServer(t, serverOptions);
const incoming = once<Sms>(resolve => { const incoming = once<Sms>(resolve => {
smpp.on('session', bound => bound.on('sms', resolve)); smpp.on('session', bound => bound.on('sms', resolve));
}); });
@@ -1079,6 +1083,69 @@ describe('graceful shutdown', () => {
assert.deepEqual(await unbound, {}); assert.deepEqual(await unbound, {});
}); });
test('close() waits for a message the application has not answered yet', async t => {
const { sent, smpp, sms } = await submitInFlight(t);
const closing = peerOf(smpp).close();
await delay(50);
await sms.sendResp({ smsId: 'answered-during-the-inbound-drain' });
assert.deepEqual(await closing, {});
assert.deepEqual((await sent).smsIds, ['answered-during-the-inbound-drain']);
});
test('gives up on a message the application never answers', async t => {
const { smpp } = await submitInFlight(t, {}, { shutdownTimeout: 50 });
const closed = await peerOf(smpp).close();
assert.ok(closed.err instanceof Error);
assert.match(closed.err.message, /1 message\(s\) unanswered/);
});
// The README's own listener answers and then sends its receipt, one turn later.
test('a receipt sent right after the response still goes out mid-drain', async t => {
const { sent, session, smpp, sms } = await submitInFlight(t);
const receipt = once<Dlr>(resolve => { session.on('dlr', resolve); });
const closing = peerOf(smpp).close();
await sms.sendResp({ smsId: 'held-through-the-drain' });
const receiptSent = await sms.sendDlr('DELIVERED');
assert.equal(receiptSent.err, undefined);
assert.equal((await receipt).smsId, 'held-through-the-drain');
assert.deepEqual(await closing, {});
assert.deepEqual((await sent).smsIds, ['held-through-the-drain']);
});
test('a message no listener took does not hold the shutdown up', async t => {
const smpp = await startServer(t, { shutdownTimeout: 30_000 });
const { session } = await connect(t, smpp);
assert.ok(session);
const bound = peerOf(smpp);
const arrived = once<PduObject>(resolve => {
bound.on('incomingPduObj', pduObj => {
if (pduObj.cmdName === 'submit_sm') resolve(pduObj);
});
});
const sent = session.sendSms({
from: '46701113311',
message: 'nobody is listening',
to: '46709771337',
});
await arrived;
await delay(50);
const started = Date.now();
assert.deepEqual(await bound.close(), {});
assert.ok(Date.now() - started < 1000);
assert.ok((await sent).err instanceof Error);
});
test('gives up on a request that outlasts shutdownTimeout', async t => { test('gives up on a request that outlasts shutdownTimeout', async t => {
const { sent, session } = await submitInFlight(t, { shutdownTimeout: 50 }); const { sent, session } = await submitInFlight(t, { shutdownTimeout: 50 });
const closed = await session.close(); const closed = await session.close();
+6 -9
View File
@@ -56,6 +56,7 @@ Rules the API follows:
| Merged multipart DLRs including across a reconnect, reassembly bounds, per-send abort, the segment cap | `test/session-extras.test.ts` | | Merged multipart DLRs including across a reconnect, reassembly bounds, per-send abort, the segment cap | `test/session-extras.test.ts` |
| `smsIdFormat`: a peer's `submit_sm_resp` and receipt ids read into one notation before they are compared | `test/dlr.test.ts`, `test/session-extras.test.ts` | | `smsIdFormat`: a peer's `submit_sm_resp` and receipt ids read into one notation before they are compared | `test/dlr.test.ts`, `test/session-extras.test.ts` |
| A draining `close()` and `unbind()`, bounded by `shutdownTimeout` or an abort | `test/session-extras.test.ts` | | A draining `close()` and `unbind()`, bounded by `shutdownTimeout` or an abort | `test/session-extras.test.ts` |
| A drain that also waits out the messages the application has not answered, with `sendDlr()` the one send that passes it | `test/session-extras.test.ts` |
| A send with no link held for the next one, and one the link dropped under counted as `unanswered` | `test/session-extras.test.ts` | | A send with no link held for the next one, and one the link dropped under counted as `unanswered` | `test/session-extras.test.ts` |
| Every runnable README example | `test/readme.test.ts` | | Every runnable README example | `test/readme.test.ts` |
| Receipt-versus-message classification by `esm_class` | `test/dlr.test.ts`, `test/session.test.ts` | | Receipt-versus-message classification by `esm_class` | `test/dlr.test.ts`, `test/session.test.ts` |
@@ -113,11 +114,6 @@ session message is a change to every call site.
## Worth doing, not blocking ## Worth doing, not blocking
- [ ] **The drain covers only what this end sent.** `close()` and `unbind()` wait on the send window,
which `sendReturn()` never enters, so a server session tears down without waiting for the
application to answer the messages it is holding — the duplicate-on-retry outcome again, in
the SMSC direction. A full inbound drain needs a completion signal the `sms` event does not
carry, so it is a public-surface decision. Raised by review, 2026-08-30.
- [ ] **Merge state does not survive a process restart.** A drop no longer discards it, but a restart - [ ] **Merge state does not survive a process restart.** A drop no longer discards it, but a restart
loses every incomplete group, and a peer has no reason to resend a receipt it already had loses every incomplete group, and a peer has no reason to resend a receipt it already had
answered. Surviving one means exposing the merge state for the application to persist and hand answered. Surviving one means exposing the merge state for the application to persist and hand
@@ -125,13 +121,14 @@ session message is a change to every call site.
- [ ] **Group the session's collaborators under `src/session/`.** Only `session.ts` imports - [ ] **Group the session's collaborators under `src/session/`.** Only `session.ts` imports
`reassembly`, `dlr-merger`, `send-window`, `link-timers`, `link-gate`, `reconnect-loop`, `reassembly`, `dlr-merger`, `send-window`, `link-timers`, `link-gate`, `reconnect-loop`,
`pending-requests` and `send-sms`, so the directory would make that boundary visible. `pending-requests` and `send-sms`, so the directory would make that boundary visible.
`pdu-transport` joined them on 2026-08-31 and `link-gate` on 2026-09-01, both without the move `pdu-transport` joined them on 2026-08-31, `link-gate` and `idle-waiters` on 2026-09-01, all
being made, so it is a move of its own now. Do it together with the extraction below rather without the move being made, so it is a move of its own now. Do it together with the extraction below rather
than before it — three reactive splits at whatever boundary fitted under the line cap is what than before it — three reactive splits at whatever boundary fitted under the line cap is what
produced the current shape. Raised by review, 2026-09-01. produced the current shape. Raised by review, 2026-09-01.
- [ ] **An `OutgoingRequests` collaborator, owning `LinkGate`, `SendWindow` and `PendingRequests`.** - [ ] **An `OutgoingRequests` collaborator, owning `LinkGate`, `SendWindow` and `PendingRequests`.**
`session.ts` sits a few lines under its 350-line cap and every split so far has been made to `session.ts` sits three lines under its 350-line cap and every split so far has been made to
get back under it. The seam that holds: one object owning the gate, the window, the pending get back under it — the inbound drain on 2026-09-01 only fitted once `ConcatReference` and the
unsent-result shape moved out to `udh.ts` and `send-sms.ts`. The seam that holds: one object owning the gate, the window, the pending
map and the retry loop, exposing a gated `request()` and the ungated door `unbind()` and the map and the retry loop, exposing a gated `request()` and the ungated door `unbind()` and the
bind already need, with `Session` calling it when a link comes up or goes down instead of bind already need, with `Session` calling it when a link comes up or goes down instead of
spreading `linkDown()` and `retrying()` across both sides. It would also close two smaller spreading `linkDown()` and `retrying()` across both sides. It would also close two smaller