From 18177053bcbb93773bec4c983dbdd63c955ea96a Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 31 Aug 2026 20:18:29 +0200 Subject: [PATCH] Re-bind a dropped client link by default, and take false to turn it off Surviving a drop is most of what the session layer is for, and behind an opt-in an application that never read the options table got none of it. `reconnect` still takes `{ minDelay, maxDelay }` to retune the backoff; `false` is the one spelling for off. --- AGENTS.md | 8 ++++++++ README.md | 8 ++++---- src/client.ts | 28 ++++++++++++++++------------ test/session-extras.test.ts | 37 +++++++++++++++++++++++++++++++++++++ todo.md | 4 ---- 5 files changed, 65 insertions(+), 20 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1ebe11f..af3fa53 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -288,6 +288,14 @@ exactly 140. and fail on every developer machine, and a committed key leaks in a public repository. Valid while the dev image has no openssl. +- **A client re-binds after a drop unless it is told not to.** Maintainer's call, 2026-08-31: + surviving a dropped link is most of what the session layer is for, and behind an opt-in an + application that never read the options table got none of it. `reconnect` takes + `{ minDelay, maxDelay }` to retune the backoff and `false` to turn it off, so absent means on and + there is one spelling for each. Only `client()` reconnects — a `server()` session is a connection + the peer opened, and nothing at this end can reopen it. The retry timer is `unref()`'d, so a + process with nothing else left to do still exits between attempts. + - **The notation a peer writes message ids in is named per place, and normalisation never reaches inside a `-` id.** An SMSC may answer `submit_sm_resp` in hex and write the receipt's `id:` in decimal, so one transform over both sides cannot make them equal — `smsIdFormat` names diff --git a/README.md b/README.md index ddb8833..49a3d15 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ by hand on top of a library; it is built in here. | | | | --- | --- | | **Keepalive** | `enquire_link` every 20 s on a quiet link, and a peer that stops answering is dropped. | -| **Reconnect with backoff** | Opt-in `reconnect` reopens the socket and re-binds, 1 s doubling to 30 s. | +| **Reconnect with backoff** | A dropped client link reopens the socket and re-binds by default, 1 s doubling to 30 s. | | **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. | | **Multipart** | Long messages split on send; concatenated `deliver_sm` reassembled into one `sms`. | @@ -99,12 +99,12 @@ Every one is optional. | `systemType`, `addressRange`, `addrTon`, `addrNpi` | `''`, `''`, `0`, `0` | The remaining bind fields, for operators that require them. | | `tls` | `false` | `true` for defaults, or a `tls.ConnectionOptions` object for a private CA or a client certificate. | | `enquireLinkInterval` | `20000` | How often to send `enquire_link` on a quiet link. | -| `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, and re-bind unless `reconnect` is `false`. | | `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 this end already sent; `0` waits forever. | | `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. | -| `reconnect` | off | `{ minDelay, maxDelay }` to re-bind automatically after a drop or an idle timeout, with exponential backoff. | +| `reconnect` | on | Re-binds after a drop or an idle timeout, backing off from `minDelay` 1 s to `maxDelay` 30 s. `{ minDelay, maxDelay }` retunes it; `false` turns it off, so a drop ends the session. | | `log` | silent | Any object with `debug`, `error`, `info`, `verbose` and `warn` methods — see [Logging](#logging). | | `signal` | — | An `AbortSignal` that cancels connecting and tears the session down. | @@ -317,7 +317,7 @@ TypeScript users can import `SmppLog` to have the compiler check one. | `dlr` | A delivery report arrives, one per segment. `smsId` is undefined when the peer marked a receipt whose body carries no readable id. `statusMsg` names `statusId` unless the peer sent a `message_state` this library cannot name — then `statusId` is that raw value and `statusMsg` is whatever the body said, or `UNKNOWN`. | | `messageDlr` | Every segment of a multipart message sent with `dlr: true` has been reported on, carrying the worst status of the segments. Merging needs the SMSC to number its segment ids `-`, which is this library's own server's convention — an SMSC that hands out unrelated ids per segment never fires it. A base is merged once: a later message the SMSC gives the same ids is reported on through `dlr` alone, and an earlier one still collecting loses its merged report as well. | | `close` | The connection closed. | -| `reconnected` | The client re-bound after a drop (only with `reconnect` configured). | +| `reconnected` | The client re-bound after a drop. Never fires with `reconnect: false`. | | `sessionError` | Something failed on a live session, including a hook or listener that threw or, if it was `async`, rejected. | | `data` | Raw bytes arrived on the socket. | | `incomingPdu` | A complete PDU arrived, as a buffer. | diff --git a/src/client.ts b/src/client.ts index 0176f19..20c7fd9 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,6 +1,6 @@ import type { ConnectionOptions } from 'node:tls'; import type { Result, VoidResult } from './result.ts'; -import type { BindType } from './session-options.ts'; +import type { BindType, ReconnectOptions } from './session-options.ts'; import type { SmppLog } from './log.ts'; import type { SmsIdFormat } from './sms-id.ts'; import type { Socket } from 'node:net'; @@ -26,7 +26,7 @@ export type ClientOptions = { maxOutstanding?: number; password?: string; port?: number; - reconnect?: { maxDelay?: number; minDelay?: number }; + reconnect?: { maxDelay?: number; minDelay?: number } | false; responseTimeout?: number; shutdownTimeout?: number; signal?: AbortSignal; @@ -139,6 +139,19 @@ async function bind(session: Session, options: ClientOptions): Promise openSocket(options), + maxDelay: tuning.maxDelay, + minDelay: tuning.minDelay, + onConnected: reconnected => bind(reconnected, options), + }; +} + function createSession(options: ClientOptions, log: SmppLog, sock: Socket): Session { const enquireLinkInterval = options.enquireLinkInterval ?? defaults.enquireLinkInterval; @@ -147,20 +160,11 @@ function createSession(options: ClientOptions, log: SmppLog, sock: Socket): Sess idleTimeout: options.idleTimeout ?? enquireLinkInterval * defaults.idleTimeoutFactor, log, maxOutstanding: options.maxOutstanding, + reconnect: reconnectFor(options), responseTimeout: options.responseTimeout, shutdownTimeout: options.shutdownTimeout, smsIdFormat: options.smsIdFormat, sock, - ...(options.reconnect - ? { - reconnect: { - connect: () => openSocket(options), - maxDelay: options.reconnect.maxDelay, - minDelay: options.reconnect.minDelay, - onConnected: reconnected => bind(reconnected, options), - }, - } - : {}), }); } diff --git a/test/session-extras.test.ts b/test/session-extras.test.ts index b721965..0aa1f9f 100644 --- a/test/session-extras.test.ts +++ b/test/session-extras.test.ts @@ -326,6 +326,43 @@ describe('sendSms()', () => { }); describe('reconnect', () => { + test('re-binds after a drop with nothing asked for, since it is the default', async t => { + const smpp = await startServer(t); + const { session } = await connect(t, smpp); + + assert.ok(session); + + const reconnected = once(resolve => { session.on('reconnected', () => { resolve(true); }); }); + + await peerOf(smpp).close(); + await reconnected; + + assert.ok(session.loggedIn); + }); + + test('schedules nothing after a drop when reconnect is false', async t => { + const smpp = await startServer(t); + const noop = (): void => undefined; + const infos: string[] = []; + const log: SmppLog = { + debug: noop, + error: noop, + info: msg => { infos.push(msg); }, + verbose: noop, + warn: noop, + }; + const { session } = await connect(t, smpp, { log, reconnect: false }); + + assert.ok(session); + + const closed = once(resolve => { session.on('close', () => { resolve(true); }); }); + + await peerOf(smpp).close(); + await closed; + + assert.ok(!infos.includes('reconnect - retrying after a drop')); + }); + test('re-binds after the connection drops, keeping the same session object', async t => { const smpp = await startServer(t); const messages: string[] = []; diff --git a/todo.md b/todo.md index 4a3b0db..2f0d7d9 100644 --- a/todo.md +++ b/todo.md @@ -168,10 +168,6 @@ session message is a change to every call site. Mirror the `onRequest` seam — return a `Dlr` to own the receipt, `undefined` to fall through to the built-in parser. -- [ ] **Turn `reconnect` on by default in `client()`.** Surviving a dropped link is most of why the - session layer exists, and it is opt-in behind an empty object today, so an application that - does not read the options table gets none of it. A default change, so it needs a decision. - ## Declined - **Throughput throttling — a TPS cap, and backing off on `ESME_RTHROTTLED`.** Two reasons, either