Drain the requests already on the wire before close() and unbind() tear down

This commit is contained in:
2026-08-30 20:08:36 +02:00
parent c06eda4582
commit bebd74b42b
13 changed files with 248 additions and 85 deletions
+9
View File
@@ -252,6 +252,15 @@ exactly 140.
one's. Every segment still reaches the application as a `dlr`. The rule covers the bases the one's. Every segment still reaches the application as a `dlr`. The rule covers the bases the
merger opened — `expect()` ignores a lone id, so a single-part message never claims one. merger opened — `expect()` ignores a lone id, so a single-part message never claims one.
- **A deliberate shutdown drains; an unusable link does not.** `close()` and `unbind()` refuse
further sends and wait for the send window to empty, not the pending map — the map misses a
segment still queued behind a full window, and finishing a half-sent multipart message is the
point. What `shutdownTimeout` leaves is torn down and reported as an `err`, and `0` waits forever
like every other timeout here. A stream the framer or the codec cannot read takes `abort()`
instead: nothing on that link can answer, so draining it would only hold a dead socket open for
the timeout. `unbind()` sends its own PDU past the window, since the drain already waited for
every slot.
- **The TLS tests build their own self-signed certificate in DER** (`test/tls.test.ts`) instead of - **The TLS tests build their own self-signed certificate in DER** (`test/tls.test.ts`) instead of
adding a devDependency or shelling out to openssl. Maintainer's call, 2026-08-26: the dev image adding a devDependency or shelling out to openssl. Maintainer's call, 2026-08-26: the dev image
`node:24.18.0-bookworm-slim` ships no openssl binary, so a shelled-out fixture would pass in CI `node:24.18.0-bookworm-slim` ships no openssl binary, so a shelled-out fixture would pass in CI
+9 -4
View File
@@ -21,6 +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 already on the wire, so a submit the SMSC accepted is not reported as a failure. |
| **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
@@ -100,6 +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; with `reconnect` set, it re-binds. | | `idleTimeout` | `2 × enquireLinkInterval` | Give up on a link the peer has stopped answering; with `reconnect` set, it re-binds. |
| `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; `0` waits forever. |
| `shutdownTimeout` | `5000` | How long `close()` and `unbind()` wait for the requests already on the wire; `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. |
| `reconnect` | off | `{ minDelay, maxDelay }` to re-bind automatically after a drop or an idle timeout, with exponential backoff. | | `reconnect` | off | `{ minDelay, maxDelay }` to re-bind automatically after a drop or an idle timeout, with exponential backoff. |
| `log` | silent | Any object with `debug`, `error`, `info`, `verbose` and `warn` methods — see [Logging](#logging). | | `log` | silent | Any object with `debug`, `error`, `info`, `verbose` and `warn` methods — see [Logging](#logging). |
@@ -206,7 +208,7 @@ smpp.on('session', session => {
}); });
console.log(smpp.port); // the port actually bound, useful when 0 was requested console.log(smpp.port); // the port actually bound, useful when 0 was requested
await smpp.close(); // stop listening and close every live session await smpp.close(); // drain and close every live session, then stop listening
``` ```
`sendDlr` accepts `SCHEDULED`, `ENROUTE`, `DELIVERED`, `EXPIRED`, `DELETED`, `UNDELIVERABLE`, `sendDlr` accepts `SCHEDULED`, `ENROUTE`, `DELIVERED`, `EXPIRED`, `DELETED`, `UNDELIVERABLE`,
@@ -228,7 +230,7 @@ A message whose `data_coding` says 8-bit binary arrives as Latin-1, so `Buffer.f
| `maxReassembly` | `1000` | Incomplete multipart messages held per session. | | `maxReassembly` | `1000` | Incomplete multipart messages held per session. |
| `maxOctets` | `67108864` | Bytes of incomplete multipart messages held per session. | | `maxOctets` | `67108864` | Bytes of incomplete multipart messages held per session. |
| `reassemblyTimeout` | `300000` | How long a late segment can still join an incomplete message. | | `reassemblyTimeout` | `300000` | How long a late segment can still join an incomplete message. |
| `responseTimeout`, `maxOutstanding`, `log`, `signal` | as for the client | | | `responseTimeout`, `shutdownTimeout`, `maxOutstanding`, `log`, `signal` | as for the client | |
### Bind direction ### Bind direction
@@ -307,8 +309,11 @@ TypeScript users can import `SmppLog` to have the compiler check one.
### Methods ### Methods
`sendSms()`, `send()`, `sendReturn()`, `unbind()` and `close()`. `send()` reaches any of the 33 SMPP `sendSms()`, `send()`, `sendReturn()`, `unbind()` and `close()`. `close()` and `unbind()` both
commands the codec knows, not just the four the session handles natively: refuse further sends, wait out the requests already on the wire up to `shutdownTimeout`, and then
tear down whatever is left; each resolves to an `err` naming how many requests it gave up on.
`send()` reaches any of the 33 SMPP commands the codec knows, not just the four the session handles
natively:
```javascript ```javascript
const { err, pduObj } = await session.send({ const { err, pduObj } = await session.send({
+5 -3
View File
@@ -27,6 +27,7 @@ export type ClientOptions = {
port?: number; port?: number;
reconnect?: { maxDelay?: number; minDelay?: number }; reconnect?: { maxDelay?: number; minDelay?: number };
responseTimeout?: number; responseTimeout?: number;
shutdownTimeout?: number;
signal?: AbortSignal; signal?: AbortSignal;
systemType?: string; systemType?: string;
tls?: ConnectionOptions | boolean; tls?: ConnectionOptions | boolean;
@@ -145,6 +146,7 @@ function createSession(options: ClientOptions, log: SmppLog, sock: Socket): Sess
log, log,
maxOutstanding: options.maxOutstanding, maxOutstanding: options.maxOutstanding,
responseTimeout: options.responseTimeout, responseTimeout: options.responseTimeout,
shutdownTimeout: options.shutdownTimeout,
sock, sock,
...(options.reconnect ...(options.reconnect
? { ? {
@@ -194,18 +196,18 @@ export async function client(options: ClientOptions = {}): Promise<Result<{ sess
const signal = options.signal; const signal = options.signal;
if (signal?.aborted === true) { if (signal?.aborted === true) {
session.close(); void session.close();
return { err: new Error('Aborted before binding') }; return { err: new Error('Aborted before binding') };
} }
// Registered before the bind: an abort landing while it is in flight has to close the session. // Registered before the bind: an abort landing while it is in flight has to close the session.
signal?.addEventListener('abort', () => { session.close(); }, { once: true }); signal?.addEventListener('abort', () => { void session.close(); }, { once: true });
const bound = await bind(session, options); const bound = await bind(session, options);
if (bound.err) { if (bound.err) {
session.close(); void session.close();
return { err: bound.err }; return { err: bound.err };
} }
+1 -1
View File
@@ -70,7 +70,7 @@ export class IncomingRequests {
break; break;
case 'unbind': case 'unbind':
await this.session.sendReturn(pduObj); await this.session.sendReturn(pduObj);
this.session.close(); await this.session.close();
break; break;
default: default:
await this.unhandled(pduObj); await this.unhandled(pduObj);
+31
View File
@@ -2,6 +2,7 @@
export class SendWindow { export class SendWindow {
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) {
@@ -28,5 +29,35 @@ export class SendWindow {
} }
this.inFlight--; this.inFlight--;
if (this.inFlight > 0) return;
for (const resolve of this.waitingForIdle.splice(0)) {
resolve();
}
}
/** Resolves once nothing is in flight, or on the timeout with how many still are. 0 never times out. */
idle(timeout: number): Promise<number> {
if (this.inFlight === 0) return Promise.resolve(0);
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);
resolve(this.inFlight);
};
if (timeout > 0) {
timer = setTimeout(done, timeout);
timer.unref();
}
this.waitingForIdle.push(done);
});
} }
} }
+14 -13
View File
@@ -34,6 +34,7 @@ export type ServerOptions = {
port?: number; port?: number;
reassemblyTimeout?: number; reassemblyTimeout?: number;
responseTimeout?: number; responseTimeout?: number;
shutdownTimeout?: number;
signal?: AbortSignal; signal?: AbortSignal;
systemId?: string; systemId?: string;
tls?: TlsOptions | boolean; tls?: TlsOptions | boolean;
@@ -114,20 +115,19 @@ export class SmppServer extends EventEmitter<ServerEvents> {
if (event !== 'serverError') this.emit('serverError', error); if (event !== 'serverError') this.emit('serverError', error);
} }
/** Stops listening and closes every live session. */ /** Stops listening and closes every live session, draining each one first. */
close(): Promise<void> { async close(): Promise<void> {
return new Promise(resolve => { const live = [...this.sessions];
for (const session of this.sessions) {
try {
session.close();
} catch (thrown: unknown) {
this.emit('serverError', errorFrom(thrown));
}
}
this.sessions.clear(); this.sessions.clear();
this.server.close(() => { resolve(); });
}); await Promise.all(live.map(async session => {
const closed = await session.close().catch((thrown: unknown) => ({ err: errorFrom(thrown) }));
if (closed.err) this.emit('serverError', closed.err);
}));
return new Promise(resolve => { this.server.close(() => { resolve(); }); });
} }
} }
@@ -233,6 +233,7 @@ function onConnection(sock: Socket, options: ServerOptions, server: SmppServer):
onRequest: (bound, pduObj) => onRequest(bound, pduObj, options), onRequest: (bound, pduObj) => onRequest(bound, pduObj, options),
reassemblyTimeout: options.reassemblyTimeout, reassemblyTimeout: options.reassemblyTimeout,
responseTimeout: options.responseTimeout, responseTimeout: options.responseTimeout,
shutdownTimeout: options.shutdownTimeout,
sock, sock,
systemId: options.systemId ?? defaults.systemId, systemId: options.systemId ?? defaults.systemId,
}); });
+5
View File
@@ -78,6 +78,8 @@ export type SessionOptions = {
reassemblyTimeout?: number | undefined; reassemblyTimeout?: number | undefined;
reconnect?: ReconnectOptions | undefined; reconnect?: ReconnectOptions | undefined;
responseTimeout?: number | undefined; responseTimeout?: number | undefined;
/** How long a drain waits for the requests already on the wire. 0 waits forever. */
shutdownTimeout?: number | undefined;
sock: Socket; sock: Socket;
/** This end's own identity, answered to the peer in place of the one it sent. */ /** This end's own identity, answered to the peer in place of the one it sent. */
systemId?: string | undefined; systemId?: string | undefined;
@@ -98,6 +100,7 @@ export const defaults = {
minDelay: 1000, minDelay: 1000,
reassemblyTimeout: 300_000, reassemblyTimeout: 300_000,
responseTimeout: 30_000, responseTimeout: 30_000,
shutdownTimeout: 5000,
systemId: defaultSystemId, systemId: defaultSystemId,
}; };
@@ -112,6 +115,7 @@ export function checkSessionOptions(options: SessionCounts): VoidResult {
['maxReassembly', options.maxReassembly ?? defaults.maxReassembly, 1], ['maxReassembly', options.maxReassembly ?? defaults.maxReassembly, 1],
['reassemblyTimeout', options.reassemblyTimeout ?? defaults.reassemblyTimeout, 0], ['reassemblyTimeout', options.reassemblyTimeout ?? defaults.reassemblyTimeout, 0],
['responseTimeout', options.responseTimeout ?? defaults.responseTimeout, 0], ['responseTimeout', options.responseTimeout ?? defaults.responseTimeout, 0],
['shutdownTimeout', options.shutdownTimeout ?? defaults.shutdownTimeout, 0],
]; ];
for (const [name, value, min] of limits) { for (const [name, value, min] of limits) {
@@ -129,4 +133,5 @@ export type SessionCounts = {
maxReassembly?: number | undefined; maxReassembly?: number | undefined;
reassemblyTimeout?: number | undefined; reassemblyTimeout?: number | undefined;
responseTimeout?: number | undefined; responseTimeout?: number | undefined;
shutdownTimeout?: number | undefined;
}; };
+57 -10
View File
@@ -67,6 +67,7 @@ export class Session extends EventEmitter<SessionEvents> {
private closed = false; private closed = false;
private concatReference = 0; private concatReference = 0;
private draining = false;
private framer = new PduFramer(); private framer = new PduFramer();
/** 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. */
@@ -160,6 +161,8 @@ export class Session extends EventEmitter<SessionEvents> {
if (this.closed) return { err: new Error('Session is closed') }; if (this.closed) return { err: new Error('Session is closed') };
if (this.draining) return { err: new Error('Session is shutting down') };
// Before the window, or a full window makes an aborted call wait for a slot it will not use. // Before the window, or a full window makes an aborted call wait for a slot it will not use.
if (options.signal?.aborted === true) { if (options.signal?.aborted === true) {
return { err: new Error('Aborted before the request was sent') }; return { err: new Error('Aborted before the request was sent') };
@@ -213,22 +216,38 @@ export class Session extends EventEmitter<SessionEvents> {
return sent; return sent;
} }
/** Unbinds politely, then closes. Many SMSCs drop the link instead of answering, which is fine. */ /**
* Drains, unbinds politely, then closes. Many SMSCs drop the link instead of answering the
* unbind, which is fine. Reports the unbind's own failure ahead of an unfinished drain.
*/
async unbind(): Promise<VoidResult> { async unbind(): Promise<VoidResult> {
this.reconnectLoop?.stop();
const drained = await this.drain();
const wasOpen = !this.closed; const wasOpen = !this.closed;
const sent = await this.send({ cmdName: 'unbind' }); // request(), not send(): the drain gate would refuse it, and the window is empty by now.
const sent = wasOpen
? await this.request({ cmdName: 'unbind' }, {})
: { err: new Error('Session is closed') };
const closedOnUnbind = wasOpen && this.closed; const closedOnUnbind = wasOpen && this.closed;
this.close(); this.shutdown();
return sent.err && !closedOnUnbind ? { err: sent.err } : {}; return sent.err && !closedOnUnbind ? { err: sent.err } : drained;
} }
/** Closes for good. A session closed this way never reconnects. */ /**
close(): void { * Closes for good: refuses new sends, waits out the requests already on the wire up to
* `shutdownTimeout`, then tears down whatever is left. A session closed this way never reconnects.
*/
async close(): Promise<VoidResult> {
this.reconnectLoop?.stop(); this.reconnectLoop?.stop();
this.teardown();
this.dlrMerger.clear(); const drained = await this.drain();
this.shutdown();
return drained;
} }
private loopFor(reconnect: ReconnectOptions | undefined): ReconnectLoop | undefined { private loopFor(reconnect: ReconnectOptions | undefined): ReconnectLoop | undefined {
@@ -311,6 +330,34 @@ export class Session extends EventEmitter<SessionEvents> {
return response; return response;
} }
/** Stops new sends and waits out the ones already issued. A dead link has nothing to wait for. */
private async drain(): Promise<VoidResult> {
this.draining = true;
this.timers.clear();
if (this.closed || this.sock.destroyed) return {};
const timeout = this.options.shutdownTimeout ?? defaults.shutdownTimeout;
const inFlight = await this.window.idle(timeout);
if (inFlight === 0) return {};
this.log.warn('session - shutting down with requests still in flight', { inFlight, timeout });
return { err: new Error(`Shut down with ${String(inFlight)} request(s) still in flight`) };
}
private shutdown(): void {
this.teardown();
this.dlrMerger.clear();
}
/** The link is unusable, so nothing can answer and there is nothing to drain. */
private abort(): void {
this.reconnectLoop?.stop();
this.shutdown();
}
private teardown(): void { private teardown(): void {
if (this.closed) return; if (this.closed) return;
@@ -348,7 +395,7 @@ export class Session extends EventEmitter<SessionEvents> {
if (framed.err) { if (framed.err) {
this.log.warn('session - unusable stream, closing', { message: framed.err.message }); this.log.warn('session - unusable stream, closing', { message: framed.err.message });
this.emit('sessionError', framed.err); this.emit('sessionError', framed.err);
this.close(); this.abort();
return; return;
} }
@@ -369,7 +416,7 @@ export class Session extends EventEmitter<SessionEvents> {
message: parsed.err.message, message: parsed.err.message,
}); });
this.emit('sessionError', parsed.err); this.emit('sessionError', parsed.err);
this.close(); this.abort();
return false; return false;
} }
+1 -1
View File
@@ -232,7 +232,7 @@ describe('a live session against the reference implementation', () => {
const port = refServer.address()?.port ?? 0; const port = refServer.address()?.port ?? 0;
const { err, session } = await client({ port }); const { err, session } = await client({ port });
t.after(() => { session?.close(); }); t.after(() => session?.close());
assert.equal(err, undefined); assert.equal(err, undefined);
assert.ok(session); assert.ok(session);
+3 -3
View File
@@ -77,7 +77,7 @@ describe('README: Client', () => {
}); });
if (err) throw err; if (err) throw err;
t.after(() => { session.close(); }); t.after(() => session.close());
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 } = await session.sendSms({
@@ -100,7 +100,7 @@ describe('README: Client', () => {
const { err, session } = await client(); const { err, session } = await client();
if (err) throw err; if (err) throw err;
t.after(() => { session.close(); }); t.after(() => session.close());
const { signal } = new AbortController(); const { signal } = new AbortController();
const [sms, sent] = await Promise.all([ const [sms, sent] = await Promise.all([
@@ -128,7 +128,7 @@ describe('README: Client', () => {
const { err, session } = await client(); const { err, session } = await client();
if (err) throw err; if (err) throw err;
t.after(() => { session.close(); }); t.after(() => session.close());
const incoming = once<Sms>(resolve => { session.on('sms', resolve); }); const incoming = once<Sms>(resolve => { session.on('sms', resolve); });
const peer = await bound; const peer = await bound;
+72 -9
View File
@@ -83,7 +83,7 @@ describe('merged delivery reports', () => {
assert.equal(report.statusMsg, 'DELIVERED'); assert.equal(report.statusMsg, 'DELIVERED');
assert.deepEqual(perSegment, ['merge-me-1', 'merge-me-2', 'merge-me-3']); assert.deepEqual(perSegment, ['merge-me-1', 'merge-me-2', 'merge-me-3']);
session.close(); await session.close();
await smpp.close(); await smpp.close();
}); });
@@ -119,7 +119,7 @@ describe('merged delivery reports', () => {
assert.equal(report.statusMsg, 'UNDELIVERABLE'); assert.equal(report.statusMsg, 'UNDELIVERABLE');
assert.equal(report.segments.length, 3); assert.equal(report.segments.length, 3);
session.close(); await session.close();
await smpp.close(); await smpp.close();
}); });
}); });
@@ -203,7 +203,7 @@ describe('sendSms()', () => {
assert.ok(sent.err instanceof Error); assert.ok(sent.err instanceof Error);
assert.match(sent.err.message, /ESME_RMSGQFUL/); assert.match(sent.err.message, /ESME_RMSGQFUL/);
session.close(); await session.close();
await smpp.close(); await smpp.close();
}); });
@@ -324,7 +324,7 @@ describe('reconnect', () => {
// Drop the connection from the server's side, as a peer restart would. // Drop the connection from the server's side, as a peer restart would.
for (const serverSession of smpp.sessions) { for (const serverSession of smpp.sessions) {
serverSession.close(); await serverSession.close();
} }
await reconnected; await reconnected;
@@ -341,7 +341,7 @@ describe('reconnect', () => {
assert.equal(sent.err, undefined); assert.equal(sent.err, undefined);
assert.deepEqual(messages, ['after reconnect']); assert.deepEqual(messages, ['after reconnect']);
session.close(); await session.close();
await smpp.close(); await smpp.close();
}); });
@@ -373,7 +373,7 @@ describe('reconnect', () => {
const reconnected = once<true>(resolve => { session.on('reconnected', () => { resolve(true); }); }); const reconnected = once<true>(resolve => { session.on('reconnected', () => { resolve(true); }); });
peerOf(smpp).close(); await peerOf(smpp).close();
await reconnected; await reconnected;
const merged = once<MessageDlr>(resolve => { session.on('messageDlr', resolve); }); const merged = once<MessageDlr>(resolve => { session.on('messageDlr', resolve); });
@@ -386,7 +386,7 @@ describe('reconnect', () => {
assert.equal(report.smsId, 'across-the-drop'); assert.equal(report.smsId, 'across-the-drop');
assert.equal(report.segments.length, 3); assert.equal(report.segments.length, 3);
session.close(); await session.close();
await smpp.close(); await smpp.close();
}); });
@@ -402,7 +402,7 @@ describe('reconnect', () => {
let reconnects = 0; let reconnects = 0;
session.on('reconnected', () => { reconnects++; }); session.on('reconnected', () => { reconnects++; });
session.close(); await session.close();
await new Promise(resolve => setTimeout(resolve, 150)); await new Promise(resolve => setTimeout(resolve, 150));
@@ -586,10 +586,73 @@ describe('AbortSignal on a send', () => {
assert.ok(sent.err instanceof Error); assert.ok(sent.err instanceof Error);
session.close(); await session.close();
for (const sock of accepted) sock.destroy(); for (const sock of accepted) sock.destroy();
await new Promise<void>(resolve => silent.close(() => { resolve(); })); await new Promise<void>(resolve => silent.close(() => { resolve(); }));
}); });
}); });
describe('graceful shutdown', () => {
async function submitInFlight(options: Parameters<typeof client>[0] = {}) {
const smpp = await startServer();
const incoming = once<Sms>(resolve => {
smpp.on('session', bound => bound.on('sms', resolve));
});
const { session } = await client({ port: smpp.port, ...options });
assert.ok(session);
const sent = session.sendSms({ from: '46701113311', message: 'answer me', to: '46709771337' });
return { sent, session, sms: await incoming, smpp };
}
test('close() waits out a submit already on the wire and refuses new ones', async () => {
const { sent, session, sms, smpp } = await submitInFlight();
const closed = session.close();
const refused = await session.sendSms({
from: '46701113311',
message: 'too late',
to: '46709771337',
});
assert.ok(refused.err instanceof Error);
assert.equal(refused.err.message, 'Session is shutting down');
await sms.sendResp({ smsId: 'answered-while-draining' });
assert.deepEqual((await sent).smsIds, ['answered-while-draining']);
assert.deepEqual(await closed, {});
await smpp.close();
});
test('unbind() waits out a submit already on the wire before it unbinds', async () => {
const { sent, session, sms, smpp } = await submitInFlight();
const unbound = session.unbind();
await sms.sendResp({ smsId: 'answered-before-unbind' });
assert.deepEqual((await sent).smsIds, ['answered-before-unbind']);
assert.deepEqual(await unbound, {});
await smpp.close();
});
test('gives up on a request that outlasts shutdownTimeout', async () => {
const { sent, session, smpp } = await submitInFlight({ shutdownTimeout: 50 });
const closed = await session.close();
assert.ok(closed.err instanceof Error);
assert.match(closed.err.message, /still in flight/);
const result = await sent;
assert.ok(result.err instanceof Error);
assert.equal(result.err.message, 'Session closed before a response arrived');
await smpp.close();
});
});
+38 -32
View File
@@ -220,7 +220,7 @@ describe('bind', () => {
assert.ok(sent.err instanceof Error); assert.ok(sent.err instanceof Error);
assert.equal(sent.err.message, 'Session closed before a response arrived'); assert.equal(sent.err.message, 'Session closed before a response arrived');
session.close(); await session.close();
await peer.close(); await peer.close();
}); });
@@ -268,7 +268,7 @@ describe('bind', () => {
assert.deepEqual(bound.userData, { userId: 123 }); assert.deepEqual(bound.userData, { userId: 123 });
session.close(); await session.close();
await smpp.close(); await smpp.close();
}); });
@@ -404,7 +404,7 @@ describe('bind', () => {
const { session } = await connect(smpp); const { session } = await connect(smpp);
assert.ok(session); assert.ok(session);
t.after(() => { session.close(); }); t.after(() => session.close());
assert.equal(session.peerInterfaceVersion, 0x50); assert.equal(session.peerInterfaceVersion, 0x50);
assert.ok(session.acceptsOptionalParams()); assert.ok(session.acceptsOptionalParams());
}); });
@@ -418,7 +418,7 @@ describe('bind', () => {
const { session } = await client({ port: peer.port }); const { session } = await client({ port: peer.port });
assert.ok(session); assert.ok(session);
t.after(() => { session.close(); }); t.after(() => session.close());
assert.equal(session.peerInterfaceVersion, 0x00); assert.equal(session.peerInterfaceVersion, 0x00);
assert.equal(session.acceptsOptionalParams(), false); assert.equal(session.acceptsOptionalParams(), false);
}); });
@@ -440,7 +440,7 @@ describe('bind direction', () => {
assert.ok(sent.pduObj); assert.ok(sent.pduObj);
assert.equal(sent.pduObj.cmdStatus, 'ESME_RINVBNDSTS'); assert.equal(sent.pduObj.cmdStatus, 'ESME_RINVBNDSTS');
session.close(); await session.close();
await smpp.close(); await smpp.close();
}); });
@@ -461,7 +461,7 @@ describe('bind direction', () => {
assert.deepEqual(sent.smsIds, []); assert.deepEqual(sent.smsIds, []);
assert.equal(arrived.length, 0); assert.equal(arrived.length, 0);
session.close(); await session.close();
await smpp.close(); await smpp.close();
}); });
@@ -481,7 +481,7 @@ describe('bind direction', () => {
assert.ok(sent.pduObj); assert.ok(sent.pduObj);
assert.equal(sent.pduObj.cmdStatus, 'ESME_RINVBNDSTS'); assert.equal(sent.pduObj.cmdStatus, 'ESME_RINVBNDSTS');
session.close(); await session.close();
await smpp.close(); await smpp.close();
}); });
@@ -506,7 +506,7 @@ describe('bind direction', () => {
assert.ok(report.err instanceof Error); assert.ok(report.err instanceof Error);
assert.match(report.err.message, /transmitter-bound/); assert.match(report.err.message, /transmitter-bound/);
session.close(); await session.close();
await smpp.close(); await smpp.close();
}); });
}); });
@@ -548,7 +548,7 @@ describe('sending', () => {
assert.equal(submitted.params.source_addr_ton, 5); assert.equal(submitted.params.source_addr_ton, 5);
assert.equal(submitted.params.dest_addr_ton, 1); assert.equal(submitted.params.dest_addr_ton, 1);
session.close(); await session.close();
await smpp.close(); await smpp.close();
}); });
@@ -577,7 +577,7 @@ describe('sending', () => {
assert.equal(sent.pduObjs.length, 4); assert.equal(sent.pduObjs.length, 4);
assert.deepEqual(sent.smsIds, ['long-id-1', 'long-id-2', 'long-id-3', 'long-id-4']); assert.deepEqual(sent.smsIds, ['long-id-1', 'long-id-2', 'long-id-3', 'long-id-4']);
session.close(); await session.close();
await smpp.close(); await smpp.close();
}); });
@@ -603,7 +603,7 @@ describe('sending', () => {
assert.equal(sms.message, message); assert.equal(sms.message, message);
assert.match(sms.smsId, /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); assert.match(sms.smsId, /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
session.close(); await session.close();
await smpp.close(); await smpp.close();
}); });
@@ -630,7 +630,7 @@ describe('sending', () => {
assert.equal(sms.message, 'تست'); assert.equal(sms.message, 'تست');
assert.ok(sms.flash); assert.ok(sms.flash);
session.close(); await session.close();
await smpp.close(); await smpp.close();
}); });
@@ -664,7 +664,7 @@ describe('sending', () => {
assert.equal(params.source_addr_npi, consts.NPI.PRIVATE); assert.equal(params.source_addr_npi, consts.NPI.PRIVATE);
assert.equal(params.source_addr_ton, consts.TON.ABBREVIATED); assert.equal(params.source_addr_ton, consts.TON.ABBREVIATED);
session.close(); await session.close();
await smpp.close(); await smpp.close();
}); });
}); });
@@ -679,7 +679,7 @@ describe('receiving', () => {
const { session } = await connect(smpp); const { session } = await connect(smpp);
assert.ok(session); assert.ok(session);
t.after(() => { session.close(); }); t.after(() => session.close());
return { peer: await bound, session }; return { peer: await bound, session };
} }
@@ -808,7 +808,7 @@ describe('delivery reports', () => {
assert.equal(receipt.tlvs.receipted_message_id?.tagValue, 'dlr-id'); assert.equal(receipt.tlvs.receipted_message_id?.tagValue, 'dlr-id');
assert.equal(receipt.tlvs.message_state?.tagValue, 2); assert.equal(receipt.tlvs.message_state?.tagValue, 2);
session.close(); await session.close();
await smpp.close(); await smpp.close();
}); });
@@ -845,7 +845,7 @@ describe('delivery reports', () => {
// 0.4.0 wrote stat:UNDELIVERABLE, which is not the spec's seven-character field. // 0.4.0 wrote stat:UNDELIVERABLE, which is not the spec's seven-character field.
assert.match(await raw, /stat:UNDELIV /); assert.match(await raw, /stat:UNDELIV /);
session.close(); await session.close();
await smpp.close(); await smpp.close();
}); });
@@ -918,7 +918,7 @@ describe('delivery reports', () => {
assert.deepEqual(perSegment, ['unrequested-1', 'unrequested-2', 'unrequested-3']); assert.deepEqual(perSegment, ['unrequested-1', 'unrequested-2', 'unrequested-3']);
assert.equal(merged, 0); assert.equal(merged, 0);
session.close(); await session.close();
await smpp.close(); await smpp.close();
}); });
}); });
@@ -1044,7 +1044,7 @@ describe('robustness', () => {
assert.ok(peak <= 2, `peak was ${String(peak)}`); assert.ok(peak <= 2, `peak was ${String(peak)}`);
session.close(); await session.close();
await smpp.close(); await smpp.close();
}); });
@@ -1062,7 +1062,7 @@ describe('robustness', () => {
assert.ok(reported instanceof Error, 'a response that never reached the wire should be reported'); assert.ok(reported instanceof Error, 'a response that never reached the wire should be reported');
assert.equal(reported.message, sent.err.message); assert.equal(reported.message, sent.err.message);
session.close(); await session.close();
}); });
test('ignores events from the socket it left behind on a reconnect', async () => { test('ignores events from the socket it left behind on a reconnect', async () => {
@@ -1080,7 +1080,7 @@ describe('robustness', () => {
const dead = session.sock; const dead = session.sock;
for (const serverSession of smpp.sessions) { for (const serverSession of smpp.sessions) {
serverSession.close(); await serverSession.close();
} }
await reconnected; await reconnected;
@@ -1099,7 +1099,7 @@ describe('robustness', () => {
assert.equal(closes, 0); assert.equal(closes, 0);
assert.equal(sent.err, undefined); assert.equal(sent.err, undefined);
session.close(); await session.close();
await smpp.close(); await smpp.close();
}); });
@@ -1129,7 +1129,7 @@ describe('robustness', () => {
const { session } = await connect(smpp); const { session } = await connect(smpp);
assert.ok(session); assert.ok(session);
t.after(() => { session.close(); }); t.after(() => session.close());
const peer = await bound; const peer = await bound;
const controller = new AbortController(); const controller = new AbortController();
@@ -1161,7 +1161,7 @@ describe('robustness', () => {
const { session } = await connect(smpp, { maxOutstanding: 1, responseTimeout: 5000 }); const { session } = await connect(smpp, { maxOutstanding: 1, responseTimeout: 5000 });
assert.ok(session); assert.ok(session);
t.after(() => { session.close(); }); t.after(() => session.close());
const held = session.sendSms({ from: '46701113311', message: 'holds the slot', to: '46709771337' }); const held = session.sendSms({ from: '46701113311', message: 'holds the slot', to: '46709771337' });
const controller = new AbortController(); const controller = new AbortController();
@@ -1177,7 +1177,7 @@ describe('robustness', () => {
assert.notEqual(aborted, false, 'an aborted send should not wait for the window'); assert.notEqual(aborted, false, 'an aborted send should not wait for the window');
assert.ok(aborted !== false && aborted.err instanceof Error); assert.ok(aborted !== false && aborted.err instanceof Error);
session.close(); await session.close();
await held; await held;
}); });
@@ -1262,7 +1262,7 @@ describe('application hooks that throw or reject', () => {
assert.ok(reported instanceof Error, 'a throwing sms listener should reach the session'); assert.ok(reported instanceof Error, 'a throwing sms listener should reach the session');
assert.equal(reported.message, 'listener exploded'); assert.equal(reported.message, 'listener exploded');
session.close(); await session.close();
await smpp.close(); await smpp.close();
}); });
@@ -1287,7 +1287,7 @@ describe('application hooks that throw or reject', () => {
assert.ok(sent.err instanceof Error); assert.ok(sent.err instanceof Error);
session.close(); await session.close();
await smpp.close(); await smpp.close();
}); });
@@ -1319,7 +1319,7 @@ describe('application hooks that throw or reject', () => {
assert.ok(reported instanceof Error, 'a rejecting sms listener should reach the session'); assert.ok(reported instanceof Error, 'a rejecting sms listener should reach the session');
assert.equal(reported.message, 'null'); assert.equal(reported.message, 'null');
session.close(); await session.close();
await smpp.close(); await smpp.close();
}); });
@@ -1343,7 +1343,7 @@ describe('application hooks that throw or reject', () => {
assert.ok(sent.err instanceof Error); assert.ok(sent.err instanceof Error);
session.close(); await session.close();
await smpp.close(); await smpp.close();
}); });
@@ -1359,7 +1359,7 @@ describe('application hooks that throw or reject', () => {
assert.ok(reported instanceof Error, 'a rejecting session listener should reach the server'); assert.ok(reported instanceof Error, 'a rejecting session listener should reach the server');
assert.equal(reported.message, 'session listener rejected'); assert.equal(reported.message, 'session listener rejected');
session?.close(); await session?.close();
await smpp.close(); await smpp.close();
}); });
@@ -1376,7 +1376,7 @@ describe('application hooks that throw or reject', () => {
await smpp.close(); await smpp.close();
assert.equal(smpp.sessions.size, 0); assert.equal(smpp.sessions.size, 0);
session.close(); await session.close();
}); });
test('refuses a send window that can never free a slot', async () => { test('refuses a send window that can never free a slot', async () => {
@@ -1387,6 +1387,12 @@ describe('application hooks that throw or reject', () => {
assert.match(err.message, /maxOutstanding/); assert.match(err.message, /maxOutstanding/);
assert.equal(session, undefined); assert.equal(session, undefined);
const negative = await connect(smpp, { shutdownTimeout: -1 });
assert.ok(negative.err instanceof Error);
assert.match(negative.err.message, /shutdownTimeout/);
assert.equal(negative.session, undefined);
await smpp.close(); await smpp.close();
}); });
@@ -1492,7 +1498,7 @@ describe('link timers', () => {
'a peer that answers nothing should time the link out', 'a peer that answers nothing should time the link out',
); );
session.close(); await session.close();
await peer.close(); await peer.close();
}); });
@@ -1510,7 +1516,7 @@ describe('link timers', () => {
assert.ok(await raceWithin(2000, back), 'a link that timed out should be reconnected'); assert.ok(await raceWithin(2000, back), 'a link that timed out should be reconnected');
session.close(); await session.close();
await peer.close(); await peer.close();
}); });
}); });
+3 -9
View File
@@ -5,7 +5,7 @@ rules there constrain every item below.
## Status ## Status
The rewrite is **feature complete and green**: 236 tests, lint and typecheck clean, verified on Node The rewrite is **feature complete and green**: 242 tests, lint and typecheck clean, verified on Node
18, 20, 22 and 24. What is left is release work and a few things worth adding before or after 1.0.0. 18, 20, 22 and 24. What is left is release work and a few things worth adding before or after 1.0.0.
```bash ```bash
@@ -55,6 +55,7 @@ Rules the API follows:
| Delivery receipt parsing, TLV and text | `test/dlr.test.ts` | | Delivery receipt parsing, TLV and text | `test/dlr.test.ts` |
| Session, client, server: bind, auth, send, reassembly, DLRs, timeouts, abort, send window | `test/session.test.ts` | | Session, client, server: bind, auth, send, reassembly, DLRs, timeouts, abort, send window | `test/session.test.ts` |
| 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` |
| A draining `close()` and `unbind()`, bounded by `shutdownTimeout` | `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` |
@@ -118,14 +119,7 @@ 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.
- [ ] **`close()` and `unbind()` drop in-flight requests instead of draining them.** `teardown()` - [ ] **`session.ts` is 465 lines.** The one seam left in it is a socket-to-PDU transport, which
settles every pending request with "Session closed before a response arrived" and destroys the
socket in the same tick, so a submit the SMSC has already accepted is reported to the caller
as a failure — the ambiguous outcome that produces a duplicate on retry. Give both a drain:
refuse new sends, wait out the pending responses up to a `shutdownTimeout`, then tear down
whatever is left. Distinct from re-queueing across a reconnect above — this is the deliberate
shutdown path, where there is nothing to come back to.
- [ ] **`session.ts` is 386 lines.** The one seam left in it is a socket-to-PDU transport, which
would move the deliberately public `sock` field out of `Session` or turn it into a getter — would move the deliberately public `sock` field out of `Session` or turn it into a getter —
a public-surface change, so it waits for a decision. a public-surface change, so it waits for a 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