Hold a send with no link for the next one, and count the rest as unanswered

This commit is contained in:
2026-09-01 08:02:37 +02:00
parent bfc85ee4dd
commit 8d9656b4e0
10 changed files with 386 additions and 52 deletions
+17 -2
View File
@@ -45,6 +45,7 @@ src/
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
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-timers.ts LinkTimers: the enquire_link heartbeat and the idle timeout link-timers.ts LinkTimers: the enquire_link heartbeat and the idle timeout
log.ts SmppLog, the logger contract, and silentLog — the default log.ts SmppLog, the logger contract, and silentLog — the default
message.ts Encoding detection, splitting, bit counting, SMPP date formatting message.ts Encoding detection, splitting, bit counting, SMPP date formatting
@@ -178,8 +179,8 @@ exactly 140.
- **The published surface is frozen at what `src/index.ts` exports today.** `Session` is exported and - **The published surface is frozen at what `src/index.ts` exports today.** `Session` is exported and
publicly constructible, which is why `SessionOptions` and `ReconnectOptions` are public too — that publicly constructible, which is why `SessionOptions` and `ReconnectOptions` are public too — that
is correct, not a leak, and it has been raised twice. The collaborators `session.ts` delegates to is correct, not a leak, and it has been raised twice. The collaborators `session.ts` delegates to
(`Reassembler`, `PendingRequests`, `SendWindow`, `ReconnectLoop`, `LinkTimers`, `DlrMerger`, (`Reassembler`, `PendingRequests`, `SendWindow`, `ReconnectLoop`, `LinkTimers`, `LinkGate`,
`submitSms`) stay unpublished so they can be reshaped. `DlrMerger`, `submitSms`) stay unpublished so they can be reshaped.
- **The sub-3.4 optional-parameter rule is a predicate, not a chokepoint.** `acceptsOptionalParams()` - **The sub-3.4 optional-parameter rule is a predicate, not a chokepoint.** `acceptsOptionalParams()`
is consulted by the library's own senders; `session.send({ tlvs })` is passed through as written, is consulted by the library's own senders; `session.send({ tlvs })` is passed through as written,
because silently stripping a caller's explicit TLVs off a deliberately public low-level surface because silently stripping a caller's explicit TLVs off a deliberately public low-level surface
@@ -345,3 +346,17 @@ exactly 140.
as no number and so reaches `expect()` and `collect()` unchanged. Normalising the base instead as no number and so reaches `expect()` and `collect()` unchanged. Normalising the base instead
would break that pair. The option is on `client()` only — a `server()` session generates its own would break that pair. The option is on `client()` only — a `server()` session generates its own
ids and writes its own receipts, so both places are already one notation. ids and writes its own receipts, so both places are already one notation.
- **A send waits for the next link only if it never reached the socket; one that did is counted, not
resent.** Maintainer's call, 2026-09-01: re-queueing everything unanswered would resend a
`submit_sm` the SMSC accepted and answered into a dead socket, which is delivered and billed
twice, while a request that never left this process can be lost for free. `LinkGate` holds a send
that has no link and `comeBackUp()` opens it once the rebind is bound, so a send issued between
links and a segment still queued behind a full window when the drop hit both go out on the new
one. The hold is bounded by `responseTimeout` rather than an option of its own — that is already
the answer to how long one request may take — so the worst case is twice it: the hold, then the
answer. It happens before `window.acquire()` rather than inside it, because the rebind's own
`bind()` goes through `session.send()` and would deadlock behind slots held by waiting sends.
`teardown()` settles what was on the wire with `UnansweredError`, which `collectSent()` counts
into `SendSmsResult.unanswered`: `smsIds` alone cannot tell a message that never left from one
whose every segment the peer took and answered into a socket that was already gone.
+12 -3
View File
@@ -100,7 +100,7 @@ Every one is optional.
| `tls` | `false` | `true` for defaults, or a `tls.ConnectionOptions` object for a private CA or a client certificate. | | `tls` | `false` | `true` for defaults, or a `tls.ConnectionOptions` object for a private CA or a client certificate. |
| `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; `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; `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. |
@@ -135,13 +135,16 @@ Messages too long for one SMS are split automatically and sent as a concatenated
one id per segment: one id per segment:
```javascript ```javascript
const { err, pduObjs, smsIds } = await session.sendSms({ from, message, to }); const { err, pduObjs, smsIds, unanswered } = await session.sendSms({ from, message, to });
``` ```
`err` is set when the SMSC refuses a segment, and it names the status it refused with. Because every `err` is set when the SMSC refuses a segment, and it names the status it refused with. Because every
segment goes on the wire together, `pduObjs` and `smsIds` then hold what the SMSC did accept — enough segment goes on the wire together, `pduObjs` and `smsIds` then hold what the SMSC did accept — enough
to reconcile against a later receipt, not enough to resend the rest, so treat a partial failure as a to reconcile against a later receipt, not enough to resend the rest, so treat a partial failure as a
failed message. A message needing more than 255 segments is refused before anything is sent, since failed message. `unanswered` counts the segments the link dropped under: the SMSC may have taken
each of them and lost only the response, so a message with `unanswered` above zero cannot be sent
again without risking a duplicate, however empty `smsIds` is. A message needing more than 255
segments is refused before anything is sent, since
the concatenation header numbers segments in a single octet. `maxSegments` lowers that ceiling: the concatenation header numbers segments in a single octet. `maxSegments` lowers that ceiling:
most handsets and SMSCs stop well short of 255, and refusing beats a message only half delivered. most handsets and SMSCs stop well short of 255, and refusing beats a message only half delivered.
@@ -341,6 +344,12 @@ const { err, pduObj } = await session.send({
}); });
``` ```
A send issued while the link is down waits for the reconnect instead of failing, and goes out on the
new link once it is bound — up to `responseTimeout`, after which it gives up having sent nothing. A
request already on the wire when the link drops is the other case: the SMSC may have taken it and
lost only the response, so it fails, and `sendSms()` counts it in `unanswered`. Neither happens with
`reconnect: false`, where a drop ends the session and every send after it is refused.
`acceptsOptionalParams()` answers whether the peer declared SMPP 3.4 or later, which is the version `acceptsOptionalParams()` answers whether the peer declared SMPP 3.4 or later, which is the version
at and above which the spec allows optional parameters to be sent to it; `peerInterfaceVersion` is at and above which the spec allows optional parameters to be sent to it; `peerInterfaceVersion` is
the version it declared, `0x00` if it declared none. The library's own senders consult the first before attaching a TLV — a the version it declared, `0x00` if it declared none. The library's own senders consult the first before attaching a TLV — a
+85
View File
@@ -0,0 +1,85 @@
import type { VoidResult } from './result.ts';
export type LinkGateOptions = {
/** Whether the link can carry nothing right now. */
isDown: () => boolean;
/** How long a request may wait for a link. 0 waits for as long as one may still arrive. */
timeout: number;
/** Whether a link that is down will be brought back. */
willReturn: () => boolean;
};
type Waiter = (result: VoidResult) => void;
function expired(): Error {
return new Error('The link did not come back in time');
}
/** Where a request with no link to go out on waits for the next one. */
export class LinkGate {
private readonly options: LinkGateOptions;
private readonly waiting = new Set<Waiter>();
constructor(options: LinkGateOptions) {
this.options = options;
}
/** When a hold starting now has to give up. 0 never does. */
deadline(): number {
return this.options.timeout > 0 ? Date.now() + this.options.timeout : 0;
}
/** Resolves once a link can carry the request, or with the reason none ever will. */
wait(deadline: number, signal: AbortSignal | undefined): Promise<VoidResult> {
if (!this.options.isDown()) return Promise.resolve({});
if (!this.options.willReturn()) return Promise.resolve({ err: new Error('Session is closed') });
const left = deadline === 0 ? 0 : deadline - Date.now();
if (deadline !== 0 && left <= 0) return Promise.resolve({ err: expired() });
return this.hold(left, signal);
}
/** A link is up: everything held goes out on it. */
open(): void {
this.release({});
}
/** No link is coming, and this is why. */
shut(err: Error): void {
this.release({ err });
}
private hold(left: number, signal: AbortSignal | undefined): Promise<VoidResult> {
return new Promise<VoidResult>(resolve => {
let timer: NodeJS.Timeout | undefined = undefined;
const settle = (result: VoidResult): void => {
if (timer) clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
this.waiting.delete(settle);
resolve(result);
};
function onAbort(): void {
settle({ err: new Error('Aborted while waiting for a link') });
}
if (left > 0) {
timer = setTimeout(() => { settle({ err: expired() }); }, left);
timer.unref();
}
signal?.addEventListener('abort', onAbort, { once: true });
this.waiting.add(settle);
});
}
private release(result: VoidResult): void {
for (const settle of [...this.waiting]) {
settle(result);
}
}
}
+11 -2
View File
@@ -12,6 +12,14 @@ type Pending = {
settle: (result: Result<{ pduObj: PduObject }>) => void; settle: (result: Result<{ pduObj: PduObject }>) => void;
}; };
/** The request went out and the link died before an answer: the peer may have accepted it. */
export class UnansweredError extends Error {
constructor() {
super('The link dropped after the request went out; the peer may have accepted it');
this.name = 'UnansweredError';
}
}
/** Hands out sequence numbers and matches responses to the requests waiting for them. */ /** Hands out sequence numbers and matches responses to the requests waiting for them. */
export class PendingRequests { export class PendingRequests {
private readonly log: SmppLog; private readonly log: SmppLog;
@@ -69,9 +77,10 @@ export class PendingRequests {
this.pending.get(seqNr)?.settle(result); this.pending.get(seqNr)?.settle(result);
} }
settleAll(err: Error): void { /** Everything still on the wire when the link died, each one possibly accepted by the peer. */
settleAll(): void {
for (const [seqNr] of this.pending) { for (const [seqNr] of this.pending) {
this.settle(seqNr, { err }); this.settle(seqNr, { err: new UnansweredError() });
} }
} }
+13 -3
View File
@@ -4,6 +4,7 @@ import type { PduObject, PduObjectInput } from './pdu.ts';
import type { Result } from './result.ts'; import type { Result } from './result.ts';
import type { SmppLog } from './log.ts'; import type { SmppLog } from './log.ts';
import type { SmsIdNotation } from './sms-id.ts'; import type { SmsIdNotation } from './sms-id.ts';
import { UnansweredError } from './pending-requests.ts';
import { consts } from './defs/constants.ts'; import { consts } from './defs/constants.ts';
import { detect } from './defs/encodings.ts'; import { detect } from './defs/encodings.ts';
import { normaliseSmsId } from './sms-id.ts'; import { normaliseSmsId } from './sms-id.ts';
@@ -28,7 +29,13 @@ export type SendSmsOptions = {
}; };
/** Both arrays hold what the peer accepted, so a partial failure names what is already delivered. */ /** Both arrays hold what the peer accepted, so a partial failure names what is already delivered. */
export type SendSmsResult = { err?: Error; pduObjs: PduObject[]; smsIds: string[] }; export type SendSmsResult = {
err?: Error;
pduObjs: PduObject[];
smsIds: string[];
/** Segments the link dropped under. The peer may have taken them, so sending again may duplicate. */
unanswered: number;
};
/** 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 = {
@@ -107,9 +114,12 @@ function collectSent(
const pduObjs: PduObject[] = []; const pduObjs: PduObject[] = [];
const smsIds: string[] = []; const smsIds: string[] = [];
let failure: Error | undefined; let failure: Error | undefined;
let unanswered = 0;
for (const one of sent) { for (const one of sent) {
if (one.err) { if (one.err) {
if (one.err instanceof UnansweredError) unanswered++;
failure ??= one.err; failure ??= one.err;
} else if (one.pduObj.cmdStatus === 'ESME_ROK') { } else if (one.pduObj.cmdStatus === 'ESME_ROK') {
pduObjs.push(one.pduObj); pduObjs.push(one.pduObj);
@@ -121,7 +131,7 @@ function collectSent(
} }
} }
return failure ? { err: failure, pduObjs, smsIds } : { pduObjs, smsIds }; return failure ? { err: failure, pduObjs, smsIds, unanswered } : { pduObjs, smsIds, unanswered };
} }
/** Puts a message on the wire as one submit_sm per segment. */ /** Puts a message on the wire as one submit_sm per segment. */
@@ -131,7 +141,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: [] }; if (refused) return { err: refused, pduObjs: [], smsIds: [], unanswered: 0 };
const multipart = segments.length > 1; const multipart = segments.length > 1;
+59 -32
View File
@@ -10,6 +10,7 @@ import type { Socket } from 'node:net';
import { DlrMerger } from './dlr-merger.ts'; import { DlrMerger } from './dlr-merger.ts';
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { IncomingRequests } from './incoming-requests.ts'; import { IncomingRequests } from './incoming-requests.ts';
import { LinkGate } from './link-gate.ts';
import { LinkTimers } from './link-timers.ts'; import { LinkTimers } from './link-timers.ts';
import { PduTransport } from './pdu-transport.ts'; import { PduTransport } from './pdu-transport.ts';
import { PendingRequests } from './pending-requests.ts'; import { PendingRequests } from './pending-requests.ts';
@@ -38,6 +39,9 @@ export { bindCommands, defaultSystemId };
/** A listener may return a promise: an `async` one that rejects is routed like one that throws. */ /** A listener may return a promise: an `async` one that rejects is routed like one that throws. */
type SessionListener<K extends keyof SessionEvents> = (...args: SessionEvents[K]) => unknown; type SessionListener<K extends keyof SessionEvents> = (...args: SessionEvents[K]) => unknown;
/** `unsent` means nothing reached the socket, so the next link can still carry this request. */
type Attempt = { result: Result<{ pduObj: PduObject }>; unsent: boolean };
export class Session extends EventEmitter<SessionEvents> { export class Session extends EventEmitter<SessionEvents> {
declare addListener: <K extends keyof SessionEvents>(event: K, listener: SessionListener<K>) => this; declare addListener: <K extends keyof SessionEvents>(event: K, listener: SessionListener<K>) => this;
declare off: <K extends keyof SessionEvents>(event: K, listener: SessionListener<K>) => this; declare off: <K extends keyof SessionEvents>(event: K, listener: SessionListener<K>) => this;
@@ -57,6 +61,7 @@ export class Session extends EventEmitter<SessionEvents> {
userData: unknown = undefined; userData: unknown = undefined;
private readonly dlrMerger: DlrMerger; private readonly dlrMerger: DlrMerger;
private readonly gate: LinkGate;
private readonly incoming: IncomingRequests; private readonly incoming: IncomingRequests;
private readonly options: SessionOptions; private readonly options: SessionOptions;
private readonly pending: PendingRequests; private readonly pending: PendingRequests;
@@ -112,6 +117,11 @@ export class Session extends EventEmitter<SessionEvents> {
max: defaults.maxDlrMerges, max: defaults.maxDlrMerges,
timeout: defaults.dlrMergeTimeout, timeout: defaults.dlrMergeTimeout,
}); });
this.gate = new LinkGate({
isDown: () => this.linkDown(),
timeout: options.responseTimeout ?? defaults.responseTimeout,
willReturn: () => this.retrying(),
});
this.incoming = new IncomingRequests({ this.incoming = new IncomingRequests({
dlrMerger: this.dlrMerger, dlrMerger: this.dlrMerger,
log: this.log, log: this.log,
@@ -160,28 +170,46 @@ export class Session extends EventEmitter<SessionEvents> {
input: PduObjectInput, input: PduObjectInput,
options: SendOptions = {}, options: SendOptions = {},
): Promise<Result<{ pduObj: PduObject }>> { ): Promise<Result<{ pduObj: PduObject }>> {
if (input.cmdName.endsWith('_resp')) { const refused = this.refuseSend(input, options);
return { err: new Error(`Use sendReturn() for responses, not send(): ${input.cmdName}`) };
}
if (this.closed) return { err: new Error('Session is closed') }; if (refused) return { err: refused };
if (this.draining) return { err: new Error('Session is shutting down') }; const deadline = this.gate.deadline();
// Before the window, or a full window makes an aborted call wait for a slot it will not use. for (;;) {
if (options.signal?.aborted === true) { const held = await this.gate.wait(deadline, options.signal);
return { err: new Error('Aborted before the request was sent') };
} if (held.err) return { err: held.err };
await this.window.acquire(); await this.window.acquire();
try { const attempt = await this.attempt(input, options).finally(() => { this.window.release(); });
return await this.request(input, options);
} finally { // Nothing reached the socket, so the next link carries it instead of the caller resending.
this.window.release(); if (!attempt.unsent || !this.linkDown() || !this.retrying()) return attempt.result;
} }
} }
/** Why a request cannot go out at all, as opposed to not yet. */
private refuseSend(input: PduObjectInput, options: SendOptions): Error | undefined {
if (input.cmdName.endsWith('_resp')) {
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 a link it will not use.
if (options.signal?.aborted === true) return new Error('Aborted before the request was sent');
return undefined;
}
/** Read through a method: a drop can land while a send is awaiting. */
private linkDown(): boolean {
return this.closed || this.sock.destroyed;
}
/** Answers a request the peer sent us. Responses are never waited on. */ /** Answers a request the peer sent us. Responses are never waited on. */
async sendReturn( async sendReturn(
pdu: PduObject, pdu: PduObject,
@@ -207,7 +235,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 { err: new Error('A receiver-bound session does not carry submit_sm'), pduObjs: [], smsIds: [] }; return {
err: new Error('A receiver-bound session does not carry submit_sm'),
pduObjs: [],
smsIds: [],
unanswered: 0,
};
} }
const sent = await submitSms({ const sent = await submitSms({
@@ -229,9 +262,9 @@ export class Session extends EventEmitter<SessionEvents> {
async unbind(): Promise<VoidResult> { async unbind(): Promise<VoidResult> {
const drained = await this.drain(undefined); const drained = await this.drain(undefined);
const wasOpen = !this.closed; const wasOpen = !this.closed;
// request(), not send(): the drain gate refuses a send, and the unbind goes out either way. // attempt(), not send(): the drain gate refuses a send, and the unbind goes out either way.
const sent = wasOpen const sent = wasOpen
? await this.request({ cmdName: 'unbind' }, {}) ? (await this.attempt({ cmdName: 'unbind' }, {})).result
: { err: new Error('Session is closed') }; : { err: new Error('Session is closed') };
const closedOnUnbind = wasOpen && this.closed; const closedOnUnbind = wasOpen && this.closed;
@@ -303,6 +336,7 @@ export class Session extends EventEmitter<SessionEvents> {
this.resetTimers(); this.resetTimers();
this.log.info('session - reconnected'); this.log.info('session - reconnected');
this.emit('reconnected'); this.emit('reconnected');
this.gate.open();
return {}; return {};
} }
@@ -312,19 +346,16 @@ export class Session extends EventEmitter<SessionEvents> {
this.closed = false; this.closed = false;
} }
private async request( private async attempt(input: PduObjectInput, options: SendOptions): Promise<Attempt> {
input: PduObjectInput,
options: SendOptions,
): Promise<Result<{ pduObj: PduObject }>> {
// pending.wait() alone settles the caller while the request still goes out to the peer. // pending.wait() alone settles the caller while the request still goes out to the peer.
if (options.signal?.aborted === true) { if (options.signal?.aborted === true) {
return { err: new Error('Aborted before the request was sent') }; return { result: { err: new Error('Aborted before the request was sent') }, unsent: false };
} }
const seqNr = this.pending.nextSeqNr(); const seqNr = this.pending.nextSeqNr();
const built = objToPdu({ ...input, seqNr }); const built = objToPdu({ ...input, seqNr });
if (built.err) return { err: built.err }; if (built.err) return { result: { err: built.err }, unsent: false };
const response = this.pending.wait(seqNr, { const response = this.pending.wait(seqNr, {
signal: options.signal, signal: options.signal,
@@ -335,10 +366,10 @@ export class Session extends EventEmitter<SessionEvents> {
if (written.err) { if (written.err) {
this.pending.settle(seqNr, { err: written.err }); this.pending.settle(seqNr, { err: written.err });
return { err: written.err }; return { result: { err: written.err }, unsent: true };
} }
return response; return { result: await response, unsent: false };
} }
/** Stops new sends and waits out the ones already issued. */ /** Stops new sends and waits out the ones already issued. */
@@ -346,13 +377,13 @@ export class Session extends EventEmitter<SessionEvents> {
this.reconnectLoop?.stop(); this.reconnectLoop?.stop();
this.draining = true; this.draining = true;
if (this.closed || this.sock.destroyed) 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 unfinished = await this.window.idle(timeout, 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.isClosed()) 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 (unfinished === 0) return {}; if (unfinished === 0) return {};
@@ -361,11 +392,6 @@ export class Session extends EventEmitter<SessionEvents> {
return { err: new Error(`Shut down with ${String(unfinished)} request(s) unfinished`) }; return { err: new Error(`Shut down with ${String(unfinished)} request(s) unfinished`) };
} }
/** Read through a method: teardown() can land while the drain is awaiting. */
private isClosed(): boolean {
return this.closed;
}
/** The session is over now, drained or not. Nothing brings it back. */ /** The session is over now, drained or not. Nothing brings it back. */
private end(): void { private end(): void {
this.reconnectLoop?.stop(); this.reconnectLoop?.stop();
@@ -378,6 +404,7 @@ export class Session extends EventEmitter<SessionEvents> {
if (this.ended) return; if (this.ended) return;
this.ended = true; this.ended = true;
this.gate.shut(new Error('Session closed before the link came back'));
this.emit('close'); this.emit('close');
} }
@@ -386,7 +413,7 @@ export class Session extends EventEmitter<SessionEvents> {
this.closed = true; this.closed = true;
this.timers.clear(); this.timers.clear();
this.pending.settleAll(new Error('Session closed before a response arrived')); this.pending.settleAll();
this.incoming.clear(); this.incoming.clear();
this.sock.destroy(); this.sock.destroy();
+2 -1
View File
@@ -83,7 +83,7 @@ describe('README: Client', () => {
closeAfter(t, session); closeAfter(t, session);
const reported = once<Dlr>(resolve => { session.on('dlr', resolve); }); const reported = once<Dlr>(resolve => { session.on('dlr', resolve); });
const { err: sendErr, smsIds } = await session.sendSms({ const { err: sendErr, smsIds, unanswered } = await session.sendSms({
dlr: true, dlr: true,
from: '46701113311', from: '46701113311',
message: '«baff»', message: '«baff»',
@@ -92,6 +92,7 @@ describe('README: Client', () => {
assert.equal(sendErr, undefined); assert.equal(sendErr, undefined);
assert.equal(smsIds.length, 1); assert.equal(smsIds.length, 1);
assert.equal(unanswered, 0);
assert.equal((await reported).smsId, smsIds[0]); assert.equal((await reported).smsId, smsIds[0]);
}); });
+178 -1
View File
@@ -557,6 +557,182 @@ describe('reconnect', () => {
}); });
}); });
describe('sends across a reconnect', () => {
/** Answers every message after the first, which is left to hold the send window open. */
function answerAfterTheFirst(smpp: SmppServer, arrived: string[]): Gate {
const first = gate();
smpp.on('session', peer => {
peer.on('sms', async sms => {
arrived.push(sms.message);
if (arrived.length === 1) first.open();
else await sms.sendResp();
});
});
return first;
}
test('holds a send issued while the link is down and puts it on the new link', async t => {
const smpp = await startServer(t);
const arrived: string[] = [];
smpp.on('session', peer => { peer.on('sms', async sms => { arrived.push(sms.message); await sms.sendResp(); }); });
const { session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } });
assert.ok(session);
const down = once<true>(resolve => { session.on('disconnected', () => { resolve(true); }); });
await peerOf(smpp).close();
await down;
const sent = await session.sendSms({ from: '46701113311', message: 'held', to: '46709771337' });
assert.equal(sent.err, undefined);
assert.equal(sent.smsIds.length, 1);
assert.equal(sent.unanswered, 0);
assert.deepEqual(arrived, ['held']);
});
test('puts a segment still queued behind a full window on the new link', async t => {
const smpp = await startServer(t);
const arrived: string[] = [];
const first = answerAfterTheFirst(smpp, arrived);
const { session } = await connect(t, smpp, {
maxOutstanding: 1,
reconnect: { maxDelay: 100, minDelay: 20 },
});
assert.ok(session);
const holding = session.sendSms({ from: '46701113311', message: 'first', to: '46709771337' });
await first.passed;
const queued = session.sendSms({ from: '46701113311', message: 'second', to: '46709771337' });
peerOf(smpp).sock.destroy();
const [dropped, resent] = await Promise.all([holding, queued]);
assert.equal(dropped.unanswered, 1);
assert.equal(resent.err, undefined, 'a request that never reached the socket is not lost with it');
assert.equal(resent.smsIds.length, 1);
assert.deepEqual(arrived, ['first', 'second']);
});
test('reports a segment the link dropped under as unanswered, not as never sent', async t => {
const smpp = await startServer(t);
const arrived = once<Sms>(resolve => { smpp.on('session', peer => peer.on('sms', resolve)); });
const { session } = await connect(t, smpp, { reconnect: false });
assert.ok(session);
const sending = session.sendSms({ from: '46701113311', message: 'in flight', to: '46709771337' });
await arrived;
peerOf(smpp).sock.destroy();
const sent = await sending;
assert.match(sent.err?.message ?? '', /may have accepted/);
assert.equal(sent.unanswered, 1, 'the peer may have accepted it, so sending it again would duplicate');
assert.deepEqual(sent.smsIds, []);
});
test('gives up a held send after responseTimeout, with nothing put on the wire', async t => {
const smpp = await startServer(t);
const { session } = await connect(t, smpp, {
reconnect: { maxDelay: 10_000, minDelay: 10_000 },
responseTimeout: 60,
});
assert.ok(session);
const down = once<true>(resolve => { session.on('disconnected', () => { resolve(true); }); });
await peerOf(smpp).close();
await down;
const sent = await session.sendSms({ from: '46701113311', message: 'held', to: '46709771337' });
assert.match(sent.err?.message ?? '', /did not come back/);
assert.equal(sent.unanswered, 0, 'nothing reached the peer, so the message can be sent again');
});
test('fails a held send when the session closes rather than leaving it waiting', async t => {
const smpp = await startServer(t);
const { session } = await connect(t, smpp, {
reconnect: { maxDelay: 10_000, minDelay: 10_000 },
});
assert.ok(session);
const down = once<true>(resolve => { session.on('disconnected', () => { resolve(true); }); });
await peerOf(smpp).close();
await down;
const sending = session.sendSms({ from: '46701113311', message: 'held', to: '46709771337' });
let settled = false;
void sending.then(() => { settled = true; });
await delay(30);
assert.equal(settled, false, 'the send waits for a link rather than failing on the spot');
await session.close();
const sent = await sending;
assert.match(sent.err?.message ?? '', /closed/);
assert.equal(sent.unanswered, 0);
});
test('aborts a held send instead of making it wait out the link', async t => {
const smpp = await startServer(t);
const { session } = await connect(t, smpp, {
reconnect: { maxDelay: 10_000, minDelay: 10_000 },
});
assert.ok(session);
const down = once<true>(resolve => { session.on('disconnected', () => { resolve(true); }); });
await peerOf(smpp).close();
await down;
const controller = new AbortController();
const sending = session.sendSms(
{ from: '46701113311', message: 'held', to: '46709771337' },
{ signal: controller.signal },
);
controller.abort();
const sent = await sending;
assert.match(sent.err?.message ?? '', /Aborted while waiting for a link/);
assert.equal(sent.unanswered, 0);
});
test('refuses a send outright once the session is over', async t => {
const smpp = await startServer(t);
const { session } = await connect(t, smpp, { reconnect: false });
assert.ok(session);
await session.close();
const sent = await session.sendSms({ from: '46701113311', message: 'too late', to: '46709771337' });
assert.match(sent.err?.message ?? '', /closed/);
assert.equal(sent.unanswered, 0);
});
});
describe('reassembly bounds', () => { describe('reassembly bounds', () => {
function segment(reference: number, part: number, total: number): PduObject { function segment(reference: number, part: number, total: number): PduObject {
const udh = Buffer.from([0x05, 0x00, 0x03, reference, total, part]); const udh = Buffer.from([0x05, 0x00, 0x03, reference, total, part]);
@@ -789,7 +965,8 @@ describe('graceful shutdown', () => {
const result = await sent; const result = await sent;
assert.ok(result.err instanceof Error); assert.ok(result.err instanceof Error);
assert.equal(result.err.message, 'Session closed before a response arrived'); assert.match(result.err.message, /may have accepted/);
assert.equal(result.unanswered, 1);
}); });
// The window empties on a drop as well as on an answer, so it cannot be what the result reads. // The window empties on a drop as well as on an answer, so it cannot be what the result reads.
+1 -1
View File
@@ -236,7 +236,7 @@ describe('bind', () => {
const sent = await session.send({ cmdName: 'enquire_link' }); const sent = await session.send({ cmdName: 'enquire_link' });
assert.ok(sent.err instanceof Error); assert.ok(sent.err instanceof Error);
assert.equal(sent.err.message, 'Session closed before a response arrived'); assert.match(sent.err.message, /may have accepted/);
}); });
test('reports an unbind the peer left unanswered on a link that stays up', async t => { test('reports an unbind the peer left unanswered on a link that stays up', async t => {
+7 -6
View File
@@ -57,6 +57,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 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` |
| A listener that throws, or rejects, reaching `sessionError`/`serverError` rather than the process | `test/session.test.ts`, `test/error-from.test.ts` | | A listener that throws, or rejects, reaching `sessionError`/`serverError` rather than the process | `test/session.test.ts`, `test/error-from.test.ts` |
@@ -113,9 +114,6 @@ session message is a change to every call site.
## Worth doing, not blocking ## Worth doing, not blocking
- [ ] **In-flight sends across a reconnect.** They currently fail with "Session closed before a
response arrived" and the caller retries. Re-queueing them automatically would be friendlier
but risks duplicate delivery, so it needs a decision before it is built.
- [ ] **The drain covers only what this end sent.** `close()` and `unbind()` wait on the send window, - [ ] **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 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 application to answer the messages it is holding — the duplicate-on-retry outcome again, in
@@ -126,9 +124,12 @@ session message is a change to every call site.
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
back, which is a public-surface decision. back, which is a public-surface decision.
- [ ] **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`, `reconnect-loop`, `pending-requests` `reassembly`, `dlr-merger`, `send-window`, `link-timers`, `link-gate`, `reconnect-loop`,
and `send-sms`, so the directory would make that boundary visible. `pdu-transport` joined them `pending-requests` and `send-sms`, so the directory would make that boundary visible.
on 2026-08-31 without the move being made, so it is a move of its own now. `pdu-transport` joined them on 2026-08-31 and `link-gate` on 2026-09-01, both without the move
being made, so it is a move of its own now. `session.ts` sits within a couple of lines of the
350-line cap again; the outgoing request path — `send()`, `refuseSend()`, `attempt()` and the
gate and window they drive — is the next thing that would come out of it.
- [ ] **Does an intermediate delivery notification deserve to be a `dlr`?** `esm_class` message type - [ ] **Does an intermediate delivery notification deserve to be a `dlr`?** `esm_class` message type
`INTERMEDIATE_DELIVERY` (0x20) is classified as a message today, so a peer that reports `INTERMEDIATE_DELIVERY` (0x20) is classified as a message today, so a peer that reports
non-final states with it hands the application a raw `id:… stat:ENROUTE` text as an inbound non-final states with it hands the application a raw `id:… stat:ENROUTE` text as an inbound