Give the gate, the window and the pending map one owner

This commit is contained in:
2026-09-01 16:52:15 +02:00
parent 3bcea108e3
commit 8becbc0ab6
12 changed files with 276 additions and 168 deletions
+13 -3
View File
@@ -66,7 +66,7 @@ src/
index.ts Public surface. Named exports only, no default export. index.ts Public surface. Named exports only, no default export.
client.ts client() -> { err, session } client.ts client() -> { err, session }
server.ts server() -> { err, server }, server owns the listener + close() server.ts server() -> { err, server }, server owns the listener + close()
session.ts Session: dispatch, events, and the collaborators below session.ts Session: the socket's life, dispatch, events, and the collaborators below
sms.ts The live handle emitted as the 'sms' event (sendResp/sendDlr) sms.ts The live handle emitted as the 'sms' event (sendResp/sendDlr)
dlr.ts Delivery receipts: text and TLV parsing, receipt status codes dlr.ts Delivery receipts: text and TLV parsing, receipt status codes
dlr-merger.ts DlrMerger: per-segment receipts counted into one MessageDlr dlr-merger.ts DlrMerger: per-segment receipts counted into one MessageDlr
@@ -79,6 +79,7 @@ src/
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
outgoing-requests.ts OutgoingRequests: the gate, the window, the pending map and the retry
pdu.ts pduToObj / objToPdu / pduReturn — synchronous, result-returning pdu.ts pduToObj / objToPdu / pduReturn — synchronous, result-returning
pdu-framer.ts PduFramer: a byte stream cut into complete PDUs pdu-framer.ts PduFramer: a byte stream cut into complete PDUs
pdu-transport.ts PduTransport: the socket a session reads complete PDUs off pdu-transport.ts PduTransport: the socket a session reads complete PDUs off
@@ -91,6 +92,7 @@ src/
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, and their reference udh.ts User data header: the concatenation fields of a long SMS, and their reference
unanswered-error.ts UnansweredError: it went out and no answer came back
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
@@ -252,7 +254,10 @@ Grouped by what each one constrains.
`super.on()` call needs one. The cost is that a subclass can no longer reach those seven through `super.on()` call needs one. The cost is that a subclass can no longer reach those seven through
`super` — re-declaring them the same way is its way out. `unknown` rather than `super` — re-declaring them the same way is its way out. `unknown` rather than
`void | Promise<void>` because a listener may return anything: `session.on('close', () => `void | Promise<void>` because a listener may return anything: `session.on('close', () =>
set.delete(session))` returns a boolean. set.delete(session))` returns a boolean. This also settles what the drain can wait on: a listener's
own promise would be the better completion signal, and reaching it needs `listeners()`, which
cannot be re-declared the same way — Node types it invariantly enough that widening `void` to
`unknown` is `TS2416`. Re-probed 2026-09-01; `sendResp()` stays the signal.
### The wire ### The wire
@@ -344,7 +349,12 @@ Grouped by what each one constrains.
`teardown()` drops what is still held for the same reason it drops inbound segments. The release `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 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, 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. and only while the message is still held — past that it is an ordinary send, because the drain it
would slip past is no longer waiting for it. `shutdownTimeout: 0` does not carry over to this
half: waiting forever is safe for the peer, whose every request is bounded by `responseTimeout`,
and unsafe for the application, which nothing bounds — `close()` is what you reach for when the
application is stuck, so it may not block on the application coming unstuck. That half falls back
to `responseTimeout`, the same answer the link gate's hold already takes.
- **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()`
+7 -5
View File
@@ -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 and the messages the application has not answered; `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 for the requests, which the peer answers or times out; the messages fall back to `responseTimeout`, since nothing but the application ends that wait. |
| `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,10 +331,12 @@ 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. They wait on the then tear down whatever is left, resolving to an `err` that says what was lost. They also wait for
`sms` events the application has not answered yet too, so a peer whose `submit_sm` is still being every `sms` the application has not called `sendResp()` on, so a peer whose `submit_sm` is still
handled is answered rather than left to re-send it; `sendDlr()` goes out during that wait, and every being handled is answered rather than left to re-send it — answering its PDUs through `sendReturn()`
other send is refused. `close({ signal })` takes an instead leaves that wait running until it gives up. `sendDlr()` is the one send the refusal lets
past, and it catches the wait when issued straight after `sendResp()`; await anything in between and
it races the shutdown like any other send. `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:
+5
View File
@@ -10,6 +10,11 @@ export class HeldMessages {
this.held.add(pduObjs); this.held.add(pduObjs);
} }
/** Whether a drain is still waiting for this message to be answered. */
has(pduObjs: PduObject[]): boolean {
return this.held.has(pduObjs);
}
release(pduObjs: PduObject[]): void { release(pduObjs: PduObject[]): void {
if (!this.held.delete(pduObjs)) return; if (!this.held.delete(pduObjs)) return;
+6 -5
View File
@@ -21,8 +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. */ /** Past a drain's refusal, for a receipt the drain is itself waiting for. */
sendHeld: (input: PduObjectInput) => Promise<Result<{ pduObj: PduObject }>>; sendPastDrain: (input: PduObjectInput) => Promise<Result<{ pduObj: PduObject }>>;
session: Session; session: Session;
smsIdFormat?: SmsIdFormat | undefined; smsIdFormat?: SmsIdFormat | undefined;
systemId?: string | undefined; systemId?: string | undefined;
@@ -35,7 +35,7 @@ export class IncomingRequests {
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 sendPastDrain: IncomingRequestsOptions['sendPastDrain'];
private readonly session: Session; private readonly session: Session;
private readonly smsIdFormat: SmsIdFormat; private readonly smsIdFormat: SmsIdFormat;
private readonly systemId: string; private readonly systemId: string;
@@ -50,7 +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.sendPastDrain = options.sendPastDrain;
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;
@@ -167,7 +167,8 @@ export class IncomingRequests {
}, { }, {
// A turn later, so a listener sending its receipt straight after the response still holds. // A turn later, so a listener sending its receipt straight after the response still holds.
onAnswered: () => { setImmediate(() => { this.held.release(pduObjs); }); }, onAnswered: () => { setImmediate(() => { this.held.release(pduObjs); }); },
send: this.sendHeld, // Past the refusal only while a drain is still waiting for this message; an ordinary send after.
send: input => (this.held.has(pduObjs) ? this.sendPastDrain(input) : this.session.send(input)),
}); });
this.held.hold(pduObjs); this.held.hold(pduObjs);
+8 -6
View File
@@ -47,9 +47,11 @@ export class LinkGate {
return this.up || this.returning ? undefined : over(); return this.up || this.returning ? undefined : over();
} }
/** When a hold starting now has to give up. 0 never does. */ /** One budget for a request, however many links it waits through. 0 never gives up. */
deadline(): number { hold(signal: AbortSignal | undefined): () => Promise<VoidResult> {
return this.timeout > 0 ? this.now() + this.timeout : 0; const deadline = this.timeout > 0 ? this.now() + this.timeout : 0;
return () => this.wait(deadline, signal);
} }
/** A link is up and bound: everything held goes out on it. */ /** A link is up and bound: everything held goes out on it. */
@@ -73,7 +75,7 @@ export class LinkGate {
} }
/** Resolves once a link can carry the request, or with the reason none ever will. */ /** Resolves once a link can carry the request, or with the reason none ever will. */
wait(deadline: number, signal: AbortSignal | undefined): Promise<VoidResult> { private wait(deadline: number, signal: AbortSignal | undefined): Promise<VoidResult> {
if (this.up) return Promise.resolve({}); if (this.up) return Promise.resolve({});
const refused = this.refusal(); const refused = this.refusal();
@@ -86,10 +88,10 @@ export class LinkGate {
if (deadline !== 0 && left <= 0) return Promise.resolve({ err: expired() }); if (deadline !== 0 && left <= 0) return Promise.resolve({ err: expired() });
return this.hold(left, signal); return this.waitForLink(left, signal);
} }
private hold(left: number, signal: AbortSignal | undefined): Promise<VoidResult> { private waitForLink(left: number, signal: AbortSignal | undefined): Promise<VoidResult> {
this.log.verbose('linkGate - holding a request until a link is back', { timeout: left }); this.log.verbose('linkGate - holding a request until a link is back', { timeout: left });
return new Promise<VoidResult>(resolve => { return new Promise<VoidResult>(resolve => {
+168
View File
@@ -0,0 +1,168 @@
import type { PduObject, PduObjectInput } from './pdu.ts';
import type { PduTransport } from './pdu-transport.ts';
import type { Result, VoidResult } from './result.ts';
import type { SendOptions } from './session-options.ts';
import type { SmppLog } from './log.ts';
import { LinkGate } from './link-gate.ts';
import { PendingRequests } from './pending-requests.ts';
import { SendWindow } from './send-window.ts';
import { UnansweredError } from './unanswered-error.ts';
import { bindCommands } from './session-options.ts';
import { objToPdu } from './pdu.ts';
export type OutgoingRequestsOptions = {
log: SmppLog;
maxOutstanding: number;
responseTimeout: number;
transport: PduTransport;
};
/** `retryOnNextLink`: the write failed, so nothing reached the socket and another link may carry it. */
type Attempt = { result: Result<{ pduObj: PduObject }>; retryOnNextLink: boolean };
function abortedBeforeSend(): Error {
return new Error('Aborted before the request was sent');
}
/** 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;
private readonly log: SmppLog;
private readonly pending: PendingRequests;
private readonly responseTimeout: number;
private readonly transport: PduTransport;
private readonly window: SendWindow;
private draining = false;
constructor(options: OutgoingRequestsOptions) {
this.gate = new LinkGate({ log: options.log, timeout: options.responseTimeout });
this.log = options.log;
this.pending = new PendingRequests(options.log);
this.responseTimeout = options.responseTimeout;
this.transport = options.transport;
this.window = new SendWindow(options.maxOutstanding);
}
/** Read through a method: a drop can land while a request is awaiting. */
linkDown(): boolean {
return !this.gate.isUp() || this.transport.sock.destroyed;
}
/** A link is up and bound, so everything held for one goes out on it. */
linkUp(): void {
this.gate.open();
}
/** The link is gone; `returning` says whether another one is on its way. */
linkLost(returning: boolean): void {
this.gate.shut(returning);
this.pending.settleAll(new Error('Session closed before a response arrived'));
}
/** Hands a response to the request waiting for it. False means nothing was. */
deliver(pduObj: PduObject): boolean {
return this.pending.deliver(pduObj);
}
/** Sends a request and resolves with the peer's response. */
request(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.pastDrain(input, options);
}
/** The same path without that refusal, which a receipt for a held message has to take. */
async pastDrain(
input: PduObjectInput,
options: SendOptions,
): Promise<Result<{ pduObj: PduObject }>> {
const refused = this.refuse(input, options);
if (refused) return { err: refused };
// A bind is what makes a link usable, so it cannot wait for one.
if (bindCommands.includes(input.cmdName)) return this.now(input, options);
const waitForLink = this.gate.hold(options.signal);
for (;;) {
const held = await waitForLink();
if (held.err) return { err: held.err };
await this.window.acquire();
const attempt = await this.attempt(input, options).finally(() => { this.window.release(); });
// Nothing reached the socket, so the next link carries it instead of the caller resending.
if (!attempt.retryOnNextLink || this.gate.isUp() || this.gate.refusal()) return attempt.result;
}
}
/** Past the gate, the window and a drain, for what has to go out either way. */
async now(input: PduObjectInput, options: SendOptions = {}): Promise<Result<{ pduObj: PduObject }>> {
return (await this.attempt(input, options)).result;
}
/** Refuses every request from here on, on a link that is already down as much as a live one. */
stopAccepting(): void {
this.draining = true;
}
/** Waits out the requests already on the wire, and says how many never finished. */
async drain(timeout: number, signal: AbortSignal | undefined): Promise<VoidResult> {
const unfinished = await this.window.idle(timeout, signal);
if (unfinished === 0) return {};
this.log.warn('session - 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.
if (options.signal?.aborted === true) return abortedBeforeSend();
// A bind skips the gate below, so the answer it would have given is given here instead.
return bindCommands.includes(input.cmdName) ? this.gate.refusal() : undefined;
}
private async attempt(input: PduObjectInput, options: SendOptions): Promise<Attempt> {
// pending.wait() alone settles the caller while the request still goes out to the peer.
if (options.signal?.aborted === true) {
return { result: { err: abortedBeforeSend() }, retryOnNextLink: false };
}
const seqNr = this.pending.nextSeqNr();
const built = objToPdu({ ...input, seqNr });
if (built.err) return { result: { err: built.err }, retryOnNextLink: false };
const response = this.pending.wait(seqNr, {
signal: options.signal,
timeout: this.responseTimeout,
});
const written = this.transport.write(built.buffer);
if (written.err) {
this.pending.settle(seqNr, { err: written.err });
return { result: { err: written.err }, retryOnNextLink: true };
}
const answered = await response;
// It went out, so a failure now means the peer may have taken it and the answer was the loss.
return { result: answered.err ? { err: new UnansweredError(answered.err) } : answered, retryOnNextLink: false };
}
}
-8
View File
@@ -12,14 +12,6 @@ type Pending = {
settle: (result: Result<{ pduObj: PduObject }>) => void; settle: (result: Result<{ pduObj: PduObject }>) => void;
}; };
/** The request went out and no answer came back: the peer may have accepted it. */
export class UnansweredError extends Error {
constructor(cause: Error) {
super(`No answer came back, so the peer may have accepted it: ${cause.message}`, { cause });
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;
+1 -1
View File
@@ -4,7 +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 { UnansweredError } from './unanswered-error.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';
+27 -117
View File
@@ -10,17 +10,15 @@ 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 { OutgoingRequests } from './outgoing-requests.ts';
import { PduTransport } from './pdu-transport.ts'; import { PduTransport } from './pdu-transport.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 { leftOf } from './idle-waiters.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, pduReturn } from './pdu.ts';
import { silentLog } from './log.ts'; import { silentLog } from './log.ts';
import { submitSms, unsent } from './send-sms.ts'; import { submitSms, unsent } from './send-sms.ts';
import { ConcatReference } from './udh.ts'; import { ConcatReference } from './udh.ts';
@@ -41,13 +39,6 @@ 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;
function abortedBeforeSend(): Error {
return new Error('Aborted before the request was sent');
}
/** `retryOnNextLink`: the write failed, so nothing reached the socket and another link may carry it. */
type Attempt = { result: Result<{ pduObj: PduObject }>; retryOnNextLink: 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;
@@ -68,17 +59,14 @@ export class Session extends EventEmitter<SessionEvents> {
private readonly concatReference = new ConcatReference(); private readonly concatReference = new ConcatReference();
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 outgoing: OutgoingRequests;
private readonly reconnectLoop: ReconnectLoop | undefined; private readonly reconnectLoop: ReconnectLoop | undefined;
private readonly timers: LinkTimers; private readonly timers: LinkTimers;
private readonly transport: PduTransport; private readonly transport: PduTransport;
private readonly window: SendWindow;
private closed = false; private closed = false;
private draining = false;
private ended = false; private ended = false;
/** A listener that throws is the application's bug; it must not become ours. Hard rule 1. */ /** A listener that throws is the application's bug; it must not become ours. Hard rule 1. */
@@ -123,7 +111,6 @@ export class Session extends EventEmitter<SessionEvents> {
max: defaults.maxDlrMerges, max: defaults.maxDlrMerges,
timeout: defaults.dlrMergeTimeout, timeout: defaults.dlrMergeTimeout,
}); });
this.gate = new LinkGate({ log: this.log, timeout: options.responseTimeout ?? defaults.responseTimeout });
this.incoming = new IncomingRequests({ this.incoming = new IncomingRequests({
dlrMerger: this.dlrMerger, dlrMerger: this.dlrMerger,
log: this.log, log: this.log,
@@ -131,12 +118,11 @@ 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, {}), sendPastDrain: input => this.outgoing.pastDrain(input, {}),
session: this, session: this,
smsIdFormat: options.smsIdFormat, smsIdFormat: options.smsIdFormat,
systemId: options.systemId, systemId: options.systemId,
}); });
this.pending = new PendingRequests(this.log);
this.reconnectLoop = this.loopFor(options.reconnect); this.reconnectLoop = this.loopFor(options.reconnect);
this.timers = new LinkTimers({ this.timers = new LinkTimers({
enquireLinkInterval: options.enquireLinkInterval, enquireLinkInterval: options.enquireLinkInterval,
@@ -147,7 +133,12 @@ export class Session extends EventEmitter<SessionEvents> {
onIdle: () => { this.teardown(); }, onIdle: () => { this.teardown(); },
}); });
this.transport = this.transportFor(options.sock); this.transport = this.transportFor(options.sock);
this.window = new SendWindow(options.maxOutstanding ?? defaults.maxOutstanding); this.outgoing = new OutgoingRequests({
log: this.log,
maxOutstanding: options.maxOutstanding ?? defaults.maxOutstanding,
responseTimeout: options.responseTimeout ?? defaults.responseTimeout,
transport: this.transport,
});
this.resetTimers(); this.resetTimers();
} }
@@ -170,56 +161,7 @@ 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. */
send(input: PduObjectInput, options: SendOptions = {}): Promise<Result<{ pduObj: PduObject }>> { 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. return this.outgoing.request(input, options);
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,
options: SendOptions = {},
): Promise<Result<{ pduObj: PduObject }>> {
const refused = this.refuseSend(input, options);
if (refused) return { err: refused };
// A bind is what makes a link usable, so it cannot wait for one.
if (bindCommands.includes(input.cmdName)) return (await this.attempt(input, options)).result;
const deadline = this.gate.deadline();
for (;;) {
const held = await this.gate.wait(deadline, options.signal);
if (held.err) return { err: held.err };
await this.window.acquire();
const attempt = await this.attempt(input, options).finally(() => { this.window.release(); });
// Nothing reached the socket, so the next link carries it instead of the caller resending.
if (!attempt.retryOnNextLink || this.gate.isUp() || !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}`);
}
// Before the gate and the window, or an aborted call waits for what it will never use.
if (options.signal?.aborted === true) return abortedBeforeSend();
// A bind skips the gate below, so the answer it would have given is given here instead.
return bindCommands.includes(input.cmdName) ? this.gate.refusal() : undefined;
}
/** Read through a method: a drop can land while a send is awaiting. */
private linkDown(): boolean {
return !this.gate.isUp() || 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. */
@@ -269,9 +211,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;
// attempt(), not send(): the drain gate refuses a send, and the unbind goes out either way. // now(), not send(): a drain refuses a send, and the unbind goes out either way.
const sent = wasOpen const sent = wasOpen
? (await this.attempt({ cmdName: 'unbind' }, {})).result ? await this.outgoing.now({ cmdName: 'unbind' })
: { err: new Error('Session is closed') }; : { err: new Error('Session is closed') };
const closedOnUnbind = wasOpen && this.closed; const closedOnUnbind = wasOpen && this.closed;
@@ -341,7 +283,7 @@ export class Session extends EventEmitter<SessionEvents> {
} }
this.resetTimers(); this.resetTimers();
this.gate.open(); this.outgoing.linkUp();
this.log.info('session - reconnected'); this.log.info('session - reconnected');
this.emit('reconnected'); this.emit('reconnected');
@@ -353,58 +295,27 @@ export class Session extends EventEmitter<SessionEvents> {
this.closed = false; this.closed = false;
} }
private async attempt(input: PduObjectInput, options: SendOptions): Promise<Attempt> {
// pending.wait() alone settles the caller while the request still goes out to the peer.
if (options.signal?.aborted === true) {
return { result: { err: abortedBeforeSend() }, retryOnNextLink: false };
}
const seqNr = this.pending.nextSeqNr();
const built = objToPdu({ ...input, seqNr });
if (built.err) return { result: { err: built.err }, retryOnNextLink: false };
const response = this.pending.wait(seqNr, {
signal: options.signal,
timeout: this.options.responseTimeout ?? defaults.responseTimeout,
});
const written = this.transport.write(built.buffer);
if (written.err) {
this.pending.settle(seqNr, { err: written.err });
return { result: { err: written.err }, retryOnNextLink: true };
}
const answered = await response;
// It went out, so a failure now means the peer may have taken it and the answer was the loss.
return { result: answered.err ? { err: new UnansweredError(answered.err) } : answered, retryOnNextLink: false };
}
/** Stops new sends and waits out the messages we hold and the requests 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.outgoing.stopAccepting();
if (this.linkDown()) return {}; if (this.outgoing.linkDown()) return {};
const timeout = this.options.shutdownTimeout ?? defaults.shutdownTimeout; const timeout = this.options.shutdownTimeout ?? defaults.shutdownTimeout;
const deadline = timeout > 0 ? Date.now() + timeout : 0; const deadline = timeout > 0 ? Date.now() + timeout : 0;
// Only the application answers a held message, so that half falls back rather than wait forever.
const answering = timeout > 0 ? timeout : (this.options.responseTimeout ?? defaults.responseTimeout);
// Answering a message can put a receipt on the wire; nothing on the wire produces a message. // 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 messages = await this.incoming.drain(answering, signal);
const unfinished = await this.window.idle(leftOf(deadline), signal); const requests = await this.outgoing.drain(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.outgoing.linkDown()) {
return { err: new Error('The session closed before the drain finished') };
}
if (messages.err) return messages; return messages.err ? messages : requests;
if (unfinished === 0) return {};
this.log.warn('session - shutting down with requests unfinished', { timeout, unfinished });
return { err: new Error(`Shut down with ${String(unfinished)} request(s) unfinished`) };
} }
/** The session is over now, drained or not. Nothing brings it back. */ /** The session is over now, drained or not. Nothing brings it back. */
@@ -419,7 +330,7 @@ export class Session extends EventEmitter<SessionEvents> {
if (this.ended) return; if (this.ended) return;
this.ended = true; this.ended = true;
this.gate.shut(false); this.outgoing.linkLost(false);
this.emit('close'); this.emit('close');
} }
@@ -427,9 +338,8 @@ export class Session extends EventEmitter<SessionEvents> {
if (this.closed) return; if (this.closed) return;
this.closed = true; this.closed = true;
this.gate.shut(this.retrying()); this.outgoing.linkLost(this.retrying());
this.timers.clear(); this.timers.clear();
this.pending.settleAll(new Error('Session closed before a response arrived'));
this.incoming.clear(); this.incoming.clear();
this.sock.destroy(); this.sock.destroy();
@@ -448,7 +358,7 @@ export class Session extends EventEmitter<SessionEvents> {
private dispatch(pduObj: PduObject): void { private dispatch(pduObj: PduObject): void {
if (isResp(pduObj)) { if (isResp(pduObj)) {
if (!this.pending.deliver(pduObj)) { if (!this.outgoing.deliver(pduObj)) {
this.log.debug('session - response with no matching request', { seqNr: pduObj.seqNr }); this.log.debug('session - response with no matching request', { seqNr: pduObj.seqNr });
} }
+7
View File
@@ -0,0 +1,7 @@
/** The request went out and no answer came back: the peer may have accepted it. */
export class UnansweredError extends Error {
constructor(cause: Error) {
super(`No answer came back, so the peer may have accepted it: ${cause.message}`, { cause });
this.name = 'UnansweredError';
}
}
+15 -4
View File
@@ -819,12 +819,12 @@ describe('LinkGate', () => {
test('refuses a hold whose deadline has already passed', async () => { test('refuses a hold whose deadline has already passed', async () => {
let now = 0; let now = 0;
const gate = new LinkGate({ log: silentLog, now: () => now, timeout: 100 }); const gate = new LinkGate({ log: silentLog, now: () => now, timeout: 100 });
const deadline = gate.deadline(); const waitForLink = gate.hold(undefined);
gate.shut(true); gate.shut(true);
now = 101; now = 101;
const held = await gate.wait(deadline, undefined); const held = await waitForLink();
assert.match(held.err?.message ?? '', /did not come back in time/); assert.match(held.err?.message ?? '', /did not come back in time/);
}); });
@@ -836,7 +836,7 @@ describe('LinkGate', () => {
gate.shut(true); gate.shut(true);
const before = timers(); const before = timers();
const held = gate.wait(gate.deadline(), undefined); const held = gate.hold(undefined)();
assert.equal(timers(), before + 1, 'an unref\'d timer is not counted here, which is the point'); assert.equal(timers(), before + 1, 'an unref\'d timer is not counted here, which is the point');
@@ -851,7 +851,7 @@ describe('LinkGate', () => {
gate.shut(true); gate.shut(true);
const held = await gate.wait(gate.deadline(), AbortSignal.abort()); const held = await gate.hold(AbortSignal.abort())();
assert.match(held.err?.message ?? '', /Aborted while waiting for a link/); assert.match(held.err?.message ?? '', /Aborted while waiting for a link/);
}); });
@@ -1102,6 +1102,17 @@ describe('graceful shutdown', () => {
assert.match(closed.err.message, /1 message\(s\) unanswered/); assert.match(closed.err.message, /1 message\(s\) unanswered/);
}); });
// Waiting forever is safe for the peer, which every request times out on. The application is not.
test('falls back to responseTimeout for a held message when the shutdown waits forever', async t => {
const { smpp } = await submitInFlight(t, {}, { responseTimeout: 200, shutdownTimeout: 0 });
const started = Date.now();
const closed = await peerOf(smpp).close();
assert.ok(closed.err instanceof Error);
assert.match(closed.err.message, /1 message\(s\) unanswered/);
assert.ok(Date.now() - started < 2000);
});
// The README's own listener answers and then sends its receipt, one turn later. // 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 => { test('a receipt sent right after the response still goes out mid-drain', async t => {
const { sent, session, smpp, sms } = await submitInFlight(t); const { sent, session, smpp, sms } = await submitInFlight(t);
+19 -19
View File
@@ -57,6 +57,7 @@ Rules the API follows:
| `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 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` |
| `OutgoingRequests`: the gate, the window, the pending map and the retry under one owner, told when a link comes up or goes down | `test/session-extras.test.ts`, `test/session.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` |
@@ -118,26 +119,25 @@ session message is a change to every call site.
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
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/`.** `session.ts` imports
`reassembly`, `dlr-merger`, `send-window`, `link-timers`, `link-gate`, `reconnect-loop`, `dlr-merger`, `incoming-requests`, `link-timers`, `outgoing-requests`, `pdu-transport`,
`pending-requests` and `send-sms`, so the directory would make that boundary visible. `reconnect-loop` and `send-sms`, and nothing else does, so the directory would make that
`pdu-transport` joined them on 2026-08-31, `link-gate` and `idle-waiters` on 2026-09-01, all boundary visible. The `OutgoingRequests` extraction this was to be done with landed on
without the move being made, so it is a move of its own now. Do it together with the extraction below rather 2026-09-01, so it is the remaining half. Raised by review, 2026-09-01.
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. - [ ] **`leftOf()` and the link gate's own budget are one concept counted twice.**
- [ ] **An `OutgoingRequests` collaborator, owning `LinkGate`, `SendWindow` and `PendingRequests`.** `idle-waiters.ts` reads what is left of a budget as `Math.max(1, deadline - now)`, because 0
`session.ts` sits three lines under its 350-line cap and every split so far has been made to means "forever" there; `link-gate.ts` runs the same subtraction and calls `<= 0` expired.
get back under it — the inbound drain on 2026-09-01 only fitted once `ConcatReference` and the Neither is reachable from the other, so nothing can disagree today, but a reader who learns one
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 and applies it to the other is wrong. A budget type both take would close it. Raised by review,
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
spreading `linkDown()` and `retrying()` across both sides. It would also close two smaller
things — `LinkGate.deadline()` and `wait(deadline)` are a two-call protocol whose only failure
mode is calling `deadline()` inside the loop, which nothing catches; `Session.linkDown()` is
read from both sides of the seam; and `UnansweredError` sits in `pending-requests.ts`, which
never uses it, for the sole edge that makes `send-sms` import that module at all. Every one of
these is unpublished, so it is a two-way door and belongs after 1.0.0. Raised by review,
2026-09-01. 2026-09-01.
- [ ] **A message the reconnect dropped is answered into the void, and reported as delivered.**
`teardown()` clears the held messages along with the inbound segments, which is right — but the
application still holds the `Sms`, so `sendResp()` writes the old link's sequence numbers to the
new socket, succeeds, and returns `{}` for a response that correlates with nothing at the peer.
`HeldMessages` now knows exactly which messages went that way, so saying so is a small
addition. Goal 2, low frequency. Raised by review, 2026-09-01.
- [ ] **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