Stop accepting before the drain and report the link that dropped during it
This commit is contained in:
@@ -252,14 +252,22 @@ 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
|
- **A deliberate shutdown drains; an unusable link and an abort do not.** `close()` and `unbind()`
|
||||||
further sends and wait for the send window to empty, not the pending map — the map misses a
|
refuse further sends and wait on the send window, not the pending map — the map misses a segment
|
||||||
segment still queued behind a full window, and finishing a half-sent multipart message is the
|
still queued behind a full window, and finishing a half-sent multipart message is the point. The
|
||||||
point. What `shutdownTimeout` leaves is torn down and reported as an `err`, and `0` waits forever
|
window counts slots, never outcomes, so it says when to stop waiting and nothing about what
|
||||||
like every other timeout here. A stream the framer or the codec cannot read takes `abort()`
|
happened: it empties on a drop too, where `teardown()` settles everything the link was carrying,
|
||||||
instead: nothing on that link can answer, so draining it would only hold a dead socket open for
|
which is why `drain()` reads `closed` before it reads the count. The wait covers what this end
|
||||||
the timeout. `unbind()` sends its own PDU past the window, since the drain already waited for
|
sent — a request the peer sent us is answered through `sendReturn()`, which never enters the
|
||||||
every slot.
|
window, so a server session waits for none of its inbound work; that half is in todo.md.
|
||||||
|
`shutdownTimeout` bounds the drain alone, and `0` waits forever like every other timeout here;
|
||||||
|
`unbind()` then waits `responseTimeout` for its own response, and sends that PDU through
|
||||||
|
`request()` past both the window and the drain gate because it must go out either way. A stream
|
||||||
|
the framer or the codec cannot read takes `end()` instead, and so does `close({ signal })` on an
|
||||||
|
aborted signal — nothing on a dead link can answer, and an abort means stop now, so draining
|
||||||
|
either would only hold a socket open for the timeout. `shutdownTimeout` stays a session option
|
||||||
|
rather than a `close()` argument: `server()` builds sessions on the caller's behalf, so the option
|
||||||
|
is the only composition point, and `close({ signal })` already covers a hard deadline.
|
||||||
|
|
||||||
- **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
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ by hand on top of a library; it is built in here.
|
|||||||
| **Submit window** | `maxOutstanding` holds requests in flight at 10; further sends queue instead of overrunning the SMSC. |
|
| **Submit window** | `maxOutstanding` holds requests in flight at 10; further sends queue instead of overrunning the SMSC. |
|
||||||
| **Delivery receipts** | Correlated by `receipted_message_id`/`message_state` where the SMSC sends them, falling back to parsing the receipt text — what Kannel and several others send. |
|
| **Delivery receipts** | Correlated by `receipted_message_id`/`message_state` where the SMSC sends them, falling back to parsing the receipt text — what Kannel and several others send. |
|
||||||
| **Multipart** | Long messages split on send; concatenated `deliver_sm` reassembled into one `sms`. |
|
| **Multipart** | Long messages split on send; concatenated `deliver_sm` reassembled into one `sms`. |
|
||||||
| **Graceful shutdown** | `close()` and `unbind()` wait out the requests already on the wire, so a submit the SMSC accepted is not reported as a failure. |
|
| **Graceful shutdown** | `close()` and `unbind()` wait out the requests this end already sent, 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
|
||||||
@@ -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; 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. |
|
| `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. |
|
||||||
| `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). |
|
||||||
@@ -208,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(); // drain and close every live session, then stop listening
|
await smpp.close(); // stop listening, then drain and close every live session
|
||||||
```
|
```
|
||||||
|
|
||||||
`sendDlr` accepts `SCHEDULED`, `ENROUTE`, `DELIVERED`, `EXPIRED`, `DELETED`, `UNDELIVERABLE`,
|
`sendDlr` accepts `SCHEDULED`, `ENROUTE`, `DELIVERED`, `EXPIRED`, `DELETED`, `UNDELIVERABLE`,
|
||||||
@@ -310,10 +310,12 @@ TypeScript users can import `SmppLog` to have the compiler check one.
|
|||||||
### Methods
|
### Methods
|
||||||
|
|
||||||
`sendSms()`, `send()`, `sendReturn()`, `unbind()` and `close()`. `close()` and `unbind()` both
|
`sendSms()`, `send()`, `sendReturn()`, `unbind()` and `close()`. `close()` and `unbind()` both
|
||||||
refuse further sends, wait out the requests already on the wire up to `shutdownTimeout`, and then
|
refuse further sends, wait out the requests this end already sent — up to `shutdownTimeout`, or
|
||||||
tear down whatever is left; each resolves to an `err` naming how many requests it gave up on.
|
until an `AbortSignal` given as `close({ signal })` says to stop now — and then tear down whatever
|
||||||
`send()` reaches any of the 33 SMPP commands the codec knows, not just the four the session handles
|
is left, resolving to an `err` that says what was lost. A request the peer sent *us* is answered
|
||||||
natively:
|
through `sendReturn()` and is not waited for, and `unbind()` waits a further `responseTimeout` for
|
||||||
|
its own response. `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({
|
||||||
|
|||||||
+3
-3
@@ -196,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) {
|
||||||
void session.close();
|
void session.close({ signal });
|
||||||
|
|
||||||
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', () => { void session.close(); }, { once: true });
|
signal?.addEventListener('abort', () => { void session.close({ signal }); }, { once: true });
|
||||||
|
|
||||||
const bound = await bind(session, options);
|
const bound = await bind(session, options);
|
||||||
|
|
||||||
if (bound.err) {
|
if (bound.err) {
|
||||||
void session.close();
|
void session.close({ signal });
|
||||||
|
|
||||||
return { err: bound.err };
|
return { err: bound.err };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export type {
|
|||||||
ServerOptions,
|
ServerOptions,
|
||||||
} from './server.ts';
|
} from './server.ts';
|
||||||
export type {
|
export type {
|
||||||
|
CloseOptions,
|
||||||
MessageDlr,
|
MessageDlr,
|
||||||
ReconnectOptions,
|
ReconnectOptions,
|
||||||
SendOptions,
|
SendOptions,
|
||||||
|
|||||||
+15
-3
@@ -37,10 +37,20 @@ export class SendWindow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolves once nothing is in flight, or on the timeout with how many still are. 0 never times out. */
|
/** Everything the caller is still owed: on the wire, plus queued behind a full window. */
|
||||||
idle(timeout: number): Promise<number> {
|
unfinished(): number {
|
||||||
|
return this.inFlight + this.waiting.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves 0 once nothing is left, or with what still is when the timeout or the signal cuts the
|
||||||
|
* wait short. A timeout of 0 waits forever.
|
||||||
|
*/
|
||||||
|
idle(timeout: number, signal?: AbortSignal): Promise<number> {
|
||||||
if (this.inFlight === 0) return Promise.resolve(0);
|
if (this.inFlight === 0) return Promise.resolve(0);
|
||||||
|
|
||||||
|
if (signal?.aborted === true) return Promise.resolve(this.unfinished());
|
||||||
|
|
||||||
return new Promise<number>(resolve => {
|
return new Promise<number>(resolve => {
|
||||||
let timer: NodeJS.Timeout | undefined = undefined;
|
let timer: NodeJS.Timeout | undefined = undefined;
|
||||||
const done = (): void => {
|
const done = (): void => {
|
||||||
@@ -49,7 +59,8 @@ export class SendWindow {
|
|||||||
if (timer) clearTimeout(timer);
|
if (timer) clearTimeout(timer);
|
||||||
if (index !== -1) this.waitingForIdle.splice(index, 1);
|
if (index !== -1) this.waitingForIdle.splice(index, 1);
|
||||||
|
|
||||||
resolve(this.inFlight);
|
signal?.removeEventListener('abort', done);
|
||||||
|
resolve(this.unfinished());
|
||||||
};
|
};
|
||||||
|
|
||||||
if (timeout > 0) {
|
if (timeout > 0) {
|
||||||
@@ -57,6 +68,7 @@ export class SendWindow {
|
|||||||
timer.unref();
|
timer.unref();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signal?.addEventListener('abort', done, { once: true });
|
||||||
this.waitingForIdle.push(done);
|
this.waitingForIdle.push(done);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-5
@@ -1,3 +1,4 @@
|
|||||||
|
import type { CloseOptions } from './session-options.ts';
|
||||||
import type { PduObject, TlvInput } from './pdu.ts';
|
import type { PduObject, TlvInput } from './pdu.ts';
|
||||||
import type { Result, VoidResult } from './result.ts';
|
import type { Result, VoidResult } from './result.ts';
|
||||||
import type { Server as NetServer, Socket } from 'node:net';
|
import type { Server as NetServer, Socket } from 'node:net';
|
||||||
@@ -115,19 +116,22 @@ 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, draining each one first. */
|
/** Stops listening, then drains and closes every session that was live when it stopped. */
|
||||||
async close(): Promise<void> {
|
async close(options: CloseOptions = {}): Promise<void> {
|
||||||
|
// Before the drain, or the listener keeps accepting connections nothing will ever close.
|
||||||
|
const stopped = new Promise<void>(resolve => { this.server.close(() => { resolve(); }); });
|
||||||
const live = [...this.sessions];
|
const live = [...this.sessions];
|
||||||
|
|
||||||
this.sessions.clear();
|
this.sessions.clear();
|
||||||
|
|
||||||
await Promise.all(live.map(async session => {
|
await Promise.all(live.map(async session => {
|
||||||
const closed = await session.close().catch((thrown: unknown) => ({ err: errorFrom(thrown) }));
|
const closed = await session.close(options)
|
||||||
|
.catch((thrown: unknown) => ({ err: errorFrom(thrown) }));
|
||||||
|
|
||||||
if (closed.err) this.emit('serverError', closed.err);
|
if (closed.err) this.emit('serverError', closed.err);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return new Promise(resolve => { this.server.close(() => { resolve(); }); });
|
return stopped;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,7 +315,9 @@ function onListening(listener: NetServer, smpp: SmppServer, options: ServerOptio
|
|||||||
|
|
||||||
log.info('server - listening', { host: options.host ?? '*', port: smpp.port });
|
log.info('server - listening', { host: options.host ?? '*', port: smpp.port });
|
||||||
|
|
||||||
options.signal?.addEventListener('abort', () => { void smpp.close(); }, { once: true });
|
const signal = options.signal;
|
||||||
|
|
||||||
|
signal?.addEventListener('abort', () => { void smpp.close({ signal }); }, { once: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Starts listening for SMPP connections. Resolves once the socket is bound. */
|
/** Starts listening for SMPP connections. Resolves once the socket is bound. */
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ export function bindCarries(bindType: BindType | undefined, cmdName: string): bo
|
|||||||
|
|
||||||
export type SendOptions = { signal?: AbortSignal | undefined };
|
export type SendOptions = { signal?: AbortSignal | undefined };
|
||||||
|
|
||||||
|
/** An already-aborted signal skips the drain; one that fires during it cuts the wait short. */
|
||||||
|
export type CloseOptions = { signal?: AbortSignal | undefined };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* First refusal on every incoming request. Returning true means the hook answered it and the
|
* First refusal on every incoming request. Returning true means the hook answered it and the
|
||||||
* built-in handling is skipped — this is how the server owns bind without the session also
|
* built-in handling is skipped — this is how the server owns bind without the session also
|
||||||
|
|||||||
+31
-27
@@ -2,7 +2,7 @@ import type { ErrorName } from './defs/errors.ts';
|
|||||||
import type { MessageDlr } from './dlr-merger.ts';
|
import type { MessageDlr } from './dlr-merger.ts';
|
||||||
import type { ParamValue } from './defs/types.ts';
|
import type { ParamValue } from './defs/types.ts';
|
||||||
import type { PduObject, PduObjectInput, TlvInput } from './pdu.ts';
|
import type { PduObject, PduObjectInput, TlvInput } from './pdu.ts';
|
||||||
import type { BindType, ReconnectOptions, SendOptions, SessionEvents, SessionOptions } from './session-options.ts';
|
import type { BindType, CloseOptions, ReconnectOptions, SendOptions, SessionEvents, SessionOptions } from './session-options.ts';
|
||||||
import type { Result, VoidResult } from './result.ts';
|
import type { Result, VoidResult } from './result.ts';
|
||||||
import type { SendSmsOptions, SendSmsResult } from './send-sms.ts';
|
import type { SendSmsOptions, SendSmsResult } from './send-sms.ts';
|
||||||
import type { SmppLog } from './log.ts';
|
import type { SmppLog } from './log.ts';
|
||||||
@@ -23,6 +23,7 @@ import { silentLog } from './log.ts';
|
|||||||
import { submitSms } from './send-sms.ts';
|
import { submitSms } from './send-sms.ts';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
|
CloseOptions,
|
||||||
MessageDlr,
|
MessageDlr,
|
||||||
ReconnectOptions,
|
ReconnectOptions,
|
||||||
SendOptions,
|
SendOptions,
|
||||||
@@ -221,17 +222,15 @@ export class Session extends EventEmitter<SessionEvents> {
|
|||||||
* unbind, which is fine. Reports the unbind's own failure ahead of an unfinished drain.
|
* 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(undefined);
|
||||||
|
|
||||||
const drained = await this.drain();
|
|
||||||
const wasOpen = !this.closed;
|
const wasOpen = !this.closed;
|
||||||
// request(), not send(): the drain gate would refuse it, and the window is empty by now.
|
// request(), 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.request({ cmdName: 'unbind' }, {})
|
||||||
: { err: new Error('Session is closed') };
|
: { err: new Error('Session is closed') };
|
||||||
const closedOnUnbind = wasOpen && this.closed;
|
const closedOnUnbind = wasOpen && this.closed;
|
||||||
|
|
||||||
this.shutdown();
|
this.end();
|
||||||
|
|
||||||
return sent.err && !closedOnUnbind ? { err: sent.err } : drained;
|
return sent.err && !closedOnUnbind ? { err: sent.err } : drained;
|
||||||
}
|
}
|
||||||
@@ -240,12 +239,10 @@ export class Session extends EventEmitter<SessionEvents> {
|
|||||||
* Closes for good: refuses new sends, waits out the requests already on the wire up to
|
* 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.
|
* `shutdownTimeout`, then tears down whatever is left. A session closed this way never reconnects.
|
||||||
*/
|
*/
|
||||||
async close(): Promise<VoidResult> {
|
async close(options: CloseOptions = {}): Promise<VoidResult> {
|
||||||
this.reconnectLoop?.stop();
|
const drained = await this.drain(options.signal);
|
||||||
|
|
||||||
const drained = await this.drain();
|
this.end();
|
||||||
|
|
||||||
this.shutdown();
|
|
||||||
|
|
||||||
return drained;
|
return drained;
|
||||||
}
|
}
|
||||||
@@ -330,34 +327,41 @@ 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> {
|
* Stops new sends and waits out the ones already issued, which only covers what this end sent:
|
||||||
|
* a request the peer sent us is answered through sendReturn(), which never enters the window.
|
||||||
|
*/
|
||||||
|
private async drain(signal: AbortSignal | undefined): Promise<VoidResult> {
|
||||||
|
this.reconnectLoop?.stop();
|
||||||
this.draining = true;
|
this.draining = true;
|
||||||
this.timers.clear();
|
|
||||||
|
|
||||||
if (this.closed || this.sock.destroyed) return {};
|
if (this.closed || this.sock.destroyed) return {};
|
||||||
|
|
||||||
const timeout = this.options.shutdownTimeout ?? defaults.shutdownTimeout;
|
const timeout = this.options.shutdownTimeout ?? defaults.shutdownTimeout;
|
||||||
const inFlight = await this.window.idle(timeout);
|
const unfinished = await this.window.idle(timeout, signal);
|
||||||
|
|
||||||
if (inFlight === 0) return {};
|
// The window empties on a drop too: teardown() settles everything the link was carrying.
|
||||||
|
if (this.isClosed()) return { err: new Error('The link dropped before the drain finished') };
|
||||||
|
|
||||||
this.log.warn('session - shutting down with requests still in flight', { inFlight, timeout });
|
if (unfinished === 0) return {};
|
||||||
|
|
||||||
return { err: new Error(`Shut down with ${String(inFlight)} request(s) still in flight`) };
|
this.log.warn('session - shutting down with requests unfinished', { timeout, unfinished });
|
||||||
|
|
||||||
|
return { err: new Error(`Shut down with ${String(unfinished)} request(s) unfinished`) };
|
||||||
}
|
}
|
||||||
|
|
||||||
private shutdown(): void {
|
/** 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. */
|
||||||
|
private end(): void {
|
||||||
|
this.reconnectLoop?.stop();
|
||||||
this.teardown();
|
this.teardown();
|
||||||
this.dlrMerger.clear();
|
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;
|
||||||
|
|
||||||
@@ -395,7 +399,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.abort();
|
this.end();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -416,7 +420,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.abort();
|
this.end();
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -646,7 +646,7 @@ describe('graceful shutdown', () => {
|
|||||||
const closed = await session.close();
|
const closed = await session.close();
|
||||||
|
|
||||||
assert.ok(closed.err instanceof Error);
|
assert.ok(closed.err instanceof Error);
|
||||||
assert.match(closed.err.message, /still in flight/);
|
assert.match(closed.err.message, /unfinished/);
|
||||||
|
|
||||||
const result = await sent;
|
const result = await sent;
|
||||||
|
|
||||||
@@ -655,4 +655,91 @@ describe('graceful shutdown', () => {
|
|||||||
|
|
||||||
await smpp.close();
|
await smpp.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The window empties on a drop as well as on an answer, so it cannot be what the result reads.
|
||||||
|
test('reports a link that dropped mid-drain rather than calling it a clean shutdown', async () => {
|
||||||
|
const { sent, session, smpp } = await submitInFlight();
|
||||||
|
const closing = session.close();
|
||||||
|
|
||||||
|
for (const bound of smpp.sessions) {
|
||||||
|
bound.sock.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
const closed = await closing;
|
||||||
|
|
||||||
|
assert.ok(closed.err instanceof Error);
|
||||||
|
assert.match(closed.err.message, /dropped/);
|
||||||
|
assert.ok((await sent).err instanceof Error);
|
||||||
|
|
||||||
|
await smpp.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The queued segments are the whole reason the drain waits on the window and not on the pending map.
|
||||||
|
test('counts the segments still queued behind a full window', async () => {
|
||||||
|
const smpp = await startServer();
|
||||||
|
const onWire = once<PduObject>(resolve => {
|
||||||
|
smpp.on('session', bound => bound.on('incomingPduObj', resolve));
|
||||||
|
});
|
||||||
|
const { session } = await client({ maxOutstanding: 1, port: smpp.port, shutdownTimeout: 50 });
|
||||||
|
|
||||||
|
assert.ok(session);
|
||||||
|
|
||||||
|
const sent = session.sendSms({
|
||||||
|
from: '46701113311',
|
||||||
|
message: 'x'.repeat(400),
|
||||||
|
to: '46709771337',
|
||||||
|
});
|
||||||
|
|
||||||
|
await onWire;
|
||||||
|
|
||||||
|
const closed = await session.close();
|
||||||
|
|
||||||
|
assert.ok(closed.err instanceof Error);
|
||||||
|
assert.match(closed.err.message, /3 request\(s\)/);
|
||||||
|
assert.ok((await sent).err instanceof Error);
|
||||||
|
|
||||||
|
await smpp.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an aborted close tears down at once instead of waiting out the drain', async () => {
|
||||||
|
const { sent, session, smpp } = await submitInFlight({ shutdownTimeout: 30_000 });
|
||||||
|
const controller = new AbortController();
|
||||||
|
const started = Date.now();
|
||||||
|
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
const closed = await session.close({ signal: controller.signal });
|
||||||
|
|
||||||
|
assert.ok(Date.now() - started < 1000);
|
||||||
|
assert.ok(closed.err instanceof Error);
|
||||||
|
assert.ok(session.sock.destroyed);
|
||||||
|
assert.ok((await sent).err instanceof Error);
|
||||||
|
|
||||||
|
await smpp.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stops accepting the moment close() is called, not when the drain ends', async () => {
|
||||||
|
const smpp = await startServer({ shutdownTimeout: 30_000 });
|
||||||
|
const arrived = once<Session>(resolve => { smpp.on('session', resolve); });
|
||||||
|
const silent = net.connect({ port: smpp.port });
|
||||||
|
|
||||||
|
silent.resume();
|
||||||
|
|
||||||
|
const bound = await arrived;
|
||||||
|
const unanswered = bound.send({ cmdName: 'enquire_link' });
|
||||||
|
const closing = smpp.close();
|
||||||
|
const late = await new Promise<boolean>(resolve => {
|
||||||
|
const sock = net.connect({ port: smpp.port });
|
||||||
|
|
||||||
|
sock.on('connect', () => { sock.destroy(); resolve(true); });
|
||||||
|
sock.on('error', () => { resolve(false); });
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(late, false);
|
||||||
|
|
||||||
|
silent.destroy();
|
||||||
|
await closing;
|
||||||
|
|
||||||
|
assert.ok((await unanswered).err instanceof Error);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ rules there constrain every item below.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
The rewrite is **feature complete and green**: 242 tests, lint and typecheck clean, verified on Node
|
The rewrite is **feature complete and green**: 246 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,7 +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` |
|
| A draining `close()` and `unbind()`, bounded by `shutdownTimeout` or an abort | `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` |
|
||||||
@@ -115,6 +115,11 @@ session message is a change to every call site.
|
|||||||
- [ ] **In-flight sends across a reconnect.** They currently fail with "Session closed before a
|
- [ ] **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
|
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.
|
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,
|
||||||
|
which `sendReturn()` never enters, so a server session tears down without waiting for the
|
||||||
|
application to answer the messages it is holding — the duplicate-on-retry outcome again, in
|
||||||
|
the SMSC direction. A full inbound drain needs a completion signal the `sms` event does not
|
||||||
|
carry, so it is a public-surface decision. Raised by review, 2026-08-30.
|
||||||
- [ ] **Merge state does not survive a process restart.** A drop no longer discards it, but a restart
|
- [ ] **Merge state does not survive a process restart.** A drop no longer discards it, but a restart
|
||||||
loses every incomplete group, and a peer has no reason to resend a receipt it already had
|
loses every incomplete group, and a peer has no reason to resend a receipt it already had
|
||||||
answered. Surviving one means exposing the merge state for the application to persist and hand
|
answered. Surviving one means exposing the merge state for the application to persist and hand
|
||||||
|
|||||||
Reference in New Issue
Block a user