diff --git a/AGENTS.md b/AGENTS.md index 3a7c258..42b42d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -193,8 +193,9 @@ decision under [The wire](docs/decisions.md#the-wire). `message-class.test.ts` and `unsendable.test.ts` is one `SendSmsDeps.send` that answers nothing, and a field added to that type fails to compile in every copy at once. - `message_id` values the library generates are UUID v7. -- A test that needs a dummy peer must `resume()` its sockets. An unread socket never processes the - peer's FIN, so `server.close()` hangs forever — that is a test bug, not a library one. +- A socket a test opens and never reads must be `resume()`d, and a `data` listener counts. An unread + socket never processes the peer's FIN, so `server.close()` hangs forever — that is a test bug, not + a library one. - Everything a test opens gets its teardown registered as it is opened, never closed on the test's last line: an assertion that throws skips that line, and the listener it leaves behind keeps `node --test` alive until CI's ten-minute cap. `test/teardown.ts` covers a session, a server and a @@ -214,6 +215,8 @@ Each file answers one question, and a fact belongs to the file whose question it - **README.md — what you can rely on, and where this is heading.** Observable behaviour, for someone using the package, plus the goals and the audience. It carries a reason only where the reason changes how you would call the thing. +- **CHANGELOG.md — what changed for a consumer, per release.** Written for the public, never for the + next agent, and a line lands there as the work ships rather than at release. - **MIGRATION.md — what a 0.4.0 consumer has to change.** Renamed and removed surface, and the behaviour that changed on the wire. - **AGENTS.md — what may not change, and why.** Hard rules, architecture, conventions, and an index @@ -299,6 +302,8 @@ the file. - Coming up is not proof a link works, so only one that outlasted `maxDelay` resets the backoff. - `reconnect: { fromStart: true }` puts the first connect and bind through that same loop, and `client()` then resolves only once it is bound. +- `connectTimeout` defaults to 10 s, bounds the whole connect including the TLS handshake, and + `false` is the one way to turn it off. - A stream this library cannot frame is a dead link; one PDU it cannot parse is not. - A deliberate shutdown drains; an unusable link and an abort do not. - Every segment of a concatenated message is answered as it arrives, so `sendResp()` on one is the diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6dc91e2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +## 0.6.0 (unreleased) + +- `client()` now bounds each connect attempt at 10 seconds, the TLS handshake included, and reports + one that expires as an ordinary connect failure, so `reconnect` retries it on its usual backoff. + A connect previously waited the operating system out, around 130 s on Linux against a host that + drops SYNs. `connectTimeout` retunes the bound, and `connectTimeout: false` restores the old wait. + +## 0.5.0 + +The TypeScript rewrite. What a 0.4.0 consumer has to change is in +[MIGRATION.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/MIGRATION.md). diff --git a/README.md b/README.md index f29ff31..3cf199d 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,7 @@ All optional. Timeouts and delays are milliseconds. | `interfaceVersion` | `0x34` | The SMPP version declared at bind. `0x50` for an SMSC that requires SMPP 5.0. | | `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. | +| `connectTimeout` | `10000` | Give up on **each connect attempt** the SMSC never completes, the TLS handshake included, and report it as an ordinary connect failure, which `reconnect` then retries. It bounds the socket and the handshake — never the `client()` call, and never the wait for the bind response, which is `responseTimeout`. `false` waits the operating system out instead, around 130 s on Linux; `0` is refused. | | `enquireLinkInterval` | `20000` | Interval between `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`. | | `responseTimeout` | `30000` | How long to wait for a response, and how long a send with no link waits for the next one. `0` waits forever. | @@ -629,6 +630,10 @@ if (isCommand(pduObj, 'submit_sm')) { | Spec tables | `cmds`, `consts`, `encodings`, `errors`, `tlvs`, `types`, the `cmdsById`, `constsById`, `errorsById` and `tlvsById` maps, and all of them grouped as `defs`. `isCommandName`, `isErrorName`, `isEncodingName`, `commandNameById` and `errorNameById` narrow a value into them. | | Types | Every option, result, event payload and table entry has a named type: `ClientOptions`, `ServerOptions`, `SendSmsOptions`, `SendSmsResult`, `Sms`, `Dlr`, `MessageDlr`, `Receipt`, `PduObject`, `PduHeader`, `SmppLog`, `Result` and the rest in `dist/index.d.ts`. | +## What changed per release + +See [CHANGELOG.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/CHANGELOG.md). + ## Migrating from larvitsmpp 0.4.0 See [MIGRATION.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/MIGRATION.md). diff --git a/docs/decisions.md b/docs/decisions.md index df1797b..05cce6a 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -524,6 +524,26 @@ rule and an index of the titles below. `LinkGate`'s hold is not — it is awaited with no other handle, so a process whose only work is `client()` would exit unbound. +- **`connectTimeout` defaults to 10 s, bounds the whole connect including the TLS handshake, and + `false` is the one way to turn it off.** Maintainer's call, 2026-09-20, serving goal 5: a connect + that never returns is one `reconnect` cannot retry, because the operating system holds the attempt + for around 130 s at Linux's default `tcp_syn_retries` and nothing above it is counting — and no + application chooses that, so it is a default rather than an option that switches on what the caller + obviously wanted. Accepted: the far end sees roughly four times the SYNs against a dead host, one + per ~40 s rather than one per ~160 s, which goal 4 tolerates because the backoff still caps the + rate. `false` spells the operating system's wait, as it does for `reconnect`, and `0` is refused + naming it, so one spelling reaches each result. What expires is reported as the ordinary connect + failure, so the loop retries it like any other, and the message names the peer and whether the TCP + connect or the TLS handshake stalled — different faults, different answers. It settles on + `secureConnect` for a TLS socket, so a peer that accepts and then says nothing is bounded the same + way a black-holed SYN is. Rejected: shipping the option with no default, which left goal 5's "an + option does not switch on the thing the caller obviously wanted" unmet, and would have cost a + second breaking minor plus a reversal of the `0` spelling to correct later. Rejected: + `socket.setTimeout()`, an idle timeout that goes on arming once the link is up. Rejected: bounding + it with `responseTimeout`, which names the wait for an answer on a link that already exists and + would retune both at once. `server()` shares the checker and ignores the option, as it already + ignores `reconnect` — nothing at that end connects out. + - **A stream this library cannot frame is a dead link; one PDU it cannot parse is not.** Maintainer's call, 2026-08-31, narrowed 2026-09-05 via the interop plan: a `command_length` below 16 or above `maxPduLength` leaves nothing that can say where the next PDU starts, so it tears the diff --git a/src/client.ts b/src/client.ts index b935c2f..f20e831 100644 --- a/src/client.ts +++ b/src/client.ts @@ -22,6 +22,7 @@ export type ClientOptions = { addrNpi?: number; addrTon?: number; bindType?: BindType; + connectTimeout?: number | false; enquireLinkInterval?: number; host?: string; idleTimeout?: number; @@ -42,6 +43,7 @@ export type ClientOptions = { const defaults = { bindType: 'transceiver', + connectTimeout: 10_000, enquireLinkInterval: 20_000, host: 'localhost', /** The idle timeout is what notices a dead link, so it has to outlast one silent probe. */ @@ -52,7 +54,30 @@ const defaults = { username: 'user', } as const; +function armConnectTimeout( + sock: Socket, + connectTimeout: number | false, + target: { peer: string; secure: boolean }, + settle: (result: Result<{ sock: Socket }>) => void, +): NodeJS.Timeout | undefined { + if (connectTimeout === false) return undefined; + + let phase = `connecting to ${target.peer}`; + + if (target.secure) { + sock.once('connect', () => { phase = `completing the TLS handshake with ${target.peer}`; }); + } + + return setTimeout(() => { + sock.destroy(); + settle({ + err: new Error(`Timed out ${phase} after ${String(connectTimeout)} ms; raise connectTimeout or set it to false`), + }); + }, connectTimeout).unref(); +} + function openSocket(options: ClientOptions): Promise> { + const connectTimeout = options.connectTimeout ?? defaults.connectTimeout; const host = options.host ?? defaults.host; const port = options.port ?? defaults.port; const secure = options.tls !== undefined && options.tls !== false; @@ -77,11 +102,14 @@ function openSocket(options: ClientOptions): Promise> { return; } - const settle = (result: Result<{ sock: Socket }>): void => { + const timer = armConnectTimeout(sock, connectTimeout, { peer: `${host}:${String(port)}`, secure }, settle); + + function settle(result: Result<{ sock: Socket }>): void { + clearTimeout(timer); sock.removeListener('error', onError); signal?.removeEventListener('abort', onAbort); resolve(result); - }; + } function onError(err: Error): void { settle({ err }); diff --git a/src/error-from.ts b/src/error-from.ts index b2ccbd4..5d77236 100644 --- a/src/error-from.ts +++ b/src/error-from.ts @@ -9,7 +9,9 @@ export function errorFrom(reason: unknown): Error { } } -/** String() throws on a null-prototype object or a symbol, so only a string or number is printed. */ +const printable: readonly string[] = ['boolean', 'number', 'string']; + +/** String() throws on a null-prototype object, so anything but these is named by its type. */ export function namedValue(value: unknown): string { - return typeof value === 'string' || typeof value === 'number' ? String(value) : typeof value; + return printable.includes(typeof value) ? String(value) : typeof value; } diff --git a/src/session-options.ts b/src/session-options.ts index c085a22..fd53842 100644 --- a/src/session-options.ts +++ b/src/session-options.ts @@ -144,14 +144,11 @@ export function checkSessionOptions(options: CheckableOptions): VoidResult { return { err: new Error('fromStart is part of the reconnect policy, spell it reconnect: { fromStart: true }') }; } - const checked = checkLimits([ - ['idleTimeout', options.idleTimeout ?? 0, 0], - ['maxOutstanding', options.maxOutstanding ?? defaults.maxOutstanding, 1], - ['maxReassembly', options.maxReassembly ?? defaults.maxReassembly, 1], - ['reassemblyTimeout', options.reassemblyTimeout ?? defaults.reassemblyTimeout, 0], - ['responseTimeout', options.responseTimeout ?? defaults.responseTimeout, 0], - ['shutdownTimeout', options.shutdownTimeout ?? defaults.shutdownTimeout, 0], - ]); + const connect = checkConnectTimeout(options.connectTimeout); + + if (connect.err) return connect; + + const checked = checkLimits(limitsOf(options)); if (checked.err) return checked; @@ -160,6 +157,35 @@ export function checkSessionOptions(options: CheckableOptions): VoidResult { return backoff.err ? backoff : checkSmsIdFormat(options.smsIdFormat); } +function limitsOf(options: CheckableOptions): [string, number, number][] { + return [ + ['idleTimeout', options.idleTimeout ?? 0, 0], + ['maxOutstanding', options.maxOutstanding ?? defaults.maxOutstanding, 1], + ['maxReassembly', options.maxReassembly ?? defaults.maxReassembly, 1], + ['reassemblyTimeout', options.reassemblyTimeout ?? defaults.reassemblyTimeout, 0], + ['responseTimeout', options.responseTimeout ?? defaults.responseTimeout, 0], + ['shutdownTimeout', options.shutdownTimeout ?? defaults.shutdownTimeout, 0], + ]; +} + +const maxTimerDelay = 2_147_483_647; + +function checkConnectTimeout(connectTimeout: unknown): VoidResult { + if (connectTimeout === undefined || connectTimeout === false) return {}; + + const got = typeof connectTimeout === 'string' ? `"${connectTimeout}"` : namedValue(connectTimeout); + + if (typeof connectTimeout !== 'number' || !Number.isInteger(connectTimeout) || connectTimeout < 1) { + return { err: new Error(`connectTimeout must be a whole number of milliseconds, 1 or more, got ${got}; false waits the OS out instead`) }; + } + + if (connectTimeout > maxTimerDelay) { + return { err: new Error(`connectTimeout must be ${String(maxTimerDelay)} ms or less (about 24 days), got ${got}; false waits the OS out instead`) }; + } + + return {}; +} + function checkLimits(limits: [string, number, number][]): VoidResult { for (const [name, value, min] of limits) { if (!Number.isInteger(value) || value < min) { @@ -238,6 +264,7 @@ function checkSmsIdFormat(smsIdFormat: unknown): VoidResult { /** What the checker reads, as it arrives: a caller without types can put anything in it. */ export type CheckableOptions = { + connectTimeout?: unknown; /** Not an option: the one spelling is inside reconnect, and this is where the other is refused. */ fromStart?: unknown; idleTimeout?: number | undefined; diff --git a/test/session-extras.test.ts b/test/session-extras.test.ts index 2667867..36b0bdb 100644 --- a/test/session-extras.test.ts +++ b/test/session-extras.test.ts @@ -84,6 +84,21 @@ function within(ms: number, promise: Promise): Promise { return Promise.race([promise, delay(ms).then((): undefined => undefined)]); } +/** A client still retrying holds a socket and a timer nothing else releases. */ +function abortAfter( + t: TestContext, + controller: AbortController, + connecting: ReturnType, +): void { + t.after(async () => { + controller.abort(); + + const { session } = await connecting; + + await session?.close({ signal: AbortSignal.abort() }); + }); +} + function submitPdu(seqNr: number, cmdStatus: ErrorName = 'ESME_ROK'): PduObject { return { cmdId: 0x00000004, @@ -798,21 +813,6 @@ describe('reconnect from the first bind', () => { return port; } - /** A client still retrying holds a socket and a timer nothing else releases. */ - function abortAfter( - t: TestContext, - controller: AbortController, - connecting: ReturnType, - ): void { - t.after(async () => { - controller.abort(); - - const { session } = await connecting; - - await session?.close({ signal: AbortSignal.abort() }); - }); - } - test('gives up on the first attempt where reconnect alone is asked for', async () => { const port = await closedPort(); const spy = logSpy(); @@ -1018,6 +1018,121 @@ describe('reconnect from the first bind', () => { }); }); +describe('connectTimeout', () => { + /** Accepts and then says nothing, so a TLS handshake started on it never completes. */ + async function stalledListener(t: TestContext): Promise<{ accepted: net.Socket[]; port: number }> { + const accepted: net.Socket[] = []; + const listener = net.createServer(sock => { + accepted.push(sock); + sock.resume(); + }); + + closeListenerAfter(t, listener, accepted); + await new Promise(resolve => { listener.listen(0, '127.0.0.1', resolve); }); + + const address = listener.address(); + + return { accepted, port: typeof address === 'object' && address !== null ? address.port : 0 }; + } + + test('gives up on a connect the peer never completes', async t => { + const { port } = await stalledListener(t); + const settled = await within(2000, client({ + connectTimeout: 150, + host: '127.0.0.1', + port, + reconnect: false, + tls: true, + })); + + assert.ok(settled, 'a handshake nothing answers is what the OS wait would swallow for minutes'); + assert.ok(settled.err instanceof Error); + assert.match( + settled.err.message, + new RegExp(`Timed out completing the TLS handshake with 127\\.0\\.0\\.1:${String(port)} after 150 ms; raise connectTimeout`), + 'a firewall and a peer that accepts then stalls need different answers, and whoever reads this has never heard of the option', + ); + assert.equal(settled.session, undefined); + }); + + // Waits the default out for real: node:test mock timers land in Node 20.4, and the floor is 18. + test('bounds a connect nobody asked to bound, at the default ten seconds', async t => { + const { port } = await stalledListener(t); + const connecting = client({ host: '127.0.0.1', port, reconnect: false, tls: true }); + const settled = await within(13_000, connecting); + + assert.ok(settled, 'no bound was armed, so nothing ever settled this connect'); + assert.ok(settled.err instanceof Error); + assert.match(settled.err.message, /after 10000 ms/); + }); + + test('retries a connect it timed out on, like any other failed attempt', async t => { + const { accepted, port } = await stalledListener(t); + const controller = new AbortController(); + const connecting = client({ + connectTimeout: 60, + host: '127.0.0.1', + port, + reconnect: { fromStart: true, maxDelay: 40, minDelay: 10 }, + signal: controller.signal, + tls: true, + }); + + abortAfter(t, controller, connecting); + await delay(400); + + assert.ok(accepted.length >= 3, `the loop retried what timed out, got ${String(accepted.length)} attempts`); + }); + + test('disarms on the connect that completed, rather than on the socket that follows it', async t => { + const smpp = await startServer(t); + const { session } = await connect(t, smpp, { connectTimeout: 200 }); + + assert.ok(session); + await delay(300); + + const probe = await session.send({ cmdName: 'enquire_link' }); + + assert.equal(probe.err, undefined); + assert.equal(session.sock.destroyed, false); + }); + + test('refuses a connect timeout that would turn itself off', async () => { + assert.match( + checkSessionOptions({ connectTimeout: 0 }).err?.message ?? '', + /false waits the OS out/, + 'off is spelled false, so 0 may not stand in for it', + ); + assert.equal(checkSessionOptions({ connectTimeout: false }).err, undefined); + assert.match(checkSessionOptions({ connectTimeout: -1 }).err?.message ?? '', /connectTimeout/); + assert.match(checkSessionOptions({ connectTimeout: 1.5 }).err?.message ?? '', /whole number/); + assert.match( + checkSessionOptions({ connectTimeout: '5000' }).err?.message ?? '', + /got "5000"/, + 'an env var read without Number() is the commonest untyped value, and it is a correct number', + ); + assert.match(checkSessionOptions({ connectTimeout: true }).err?.message ?? '', /got true/); + assert.match( + checkSessionOptions({ connectTimeout: 2_147_483_648 }).err?.message ?? '', + /2147483647 ms or less \(about 24 days\), got 2147483648; false waits the OS out instead/, + 'a delay Node cannot hold in 32 bits fires after 1 ms, the inverse of what it asked for', + ); + assert.equal(checkSessionOptions({ connectTimeout: 2_147_483_647 }).err, undefined); + assert.equal(checkSessionOptions({ connectTimeout: 1000 }).err, undefined); + + const refused = await client({ connectTimeout: 0, port: 1 }); + + assert.ok(refused.err instanceof Error); + assert.match(refused.err.message, /false waits/, 'the socket may not be opened before the option is refused'); + assert.equal(refused.session, undefined); + + const off = await client({ connectTimeout: false, port: 1 }); + + assert.ok(off.err instanceof Error); + assert.match(off.err.message, /ECONNREFUSED/, 'false opts out of the bound without breaking the connect'); + }); +}); + describe('sends across a reconnect', () => { /** Answers every message after the first, which is left to hold the send window open. */ function answerAfterTheFirst(smpp: SmppServer, arrived: string[]): Latch { diff --git a/test/session.test.ts b/test/session.test.ts index db52bc8..fa7d0a1 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -1493,7 +1493,6 @@ describe('robustness', () => { // 0.4.0 registered a listener per sequence number and waited forever, leaking one per call. test('gives up on a peer that never answers', async t => { const accepted: net.Socket[] = []; - // resume() so the socket drains; an unread socket never notices the peer hanging up. const silent = net.createServer(sock => { accepted.push(sock); sock.resume(); }); await new Promise(resolve => silent.listen(0, resolve)); diff --git a/todo.md b/todo.md index c6f7324..108933e 100644 --- a/todo.md +++ b/todo.md @@ -179,16 +179,22 @@ the rewrite, for a dependency added later. Maintainer's call, 2026-09-14. ## Worth doing, not blocking -- [ ] **Give up a connect after `connectTimeout` instead of waiting the OS out.** `openSocket()` in - `client.ts` settles only on `connect`/`secureConnect`, a socket `error`, or the caller's - `signal`, and `reconnect-loop.ts` times nothing but its backoff — so a host that drops SYNs - stalls every attempt for the OS TCP timeout, around 130 s on Linux at the default - `tcp_syn_retries`, and `reconnect.fromStart` cannot retry what never returns. The option aborts - the socket and returns the ordinary connect failure; absent, the wait stays exactly 0.5.0's, so - a call passing no options is unchanged (goal 6). Whether a later major makes it a default is - the open half. Found comparing 0.5.0 with the seven `larvitsmpp` forks, 2026-09-20 — - [SwiftHero/larvitsmpp](https://github.com/SwiftHero/larvitsmpp) added a 10 s connect timeout in - 2017, and it is the one fork change with no equivalent here. +- [ ] **Cut the three teardown sentences `test/teardown.ts` already says.** Under AGENTS.md's + Conventions, "`test/teardown.ts` covers a session, a server and a listener" restates its two + exported names, "Its close aborts rather than drains" restates `closeAfter`'s own doc comment, + and the `net.Server.close()` sentence restates `closeListenerAfter`'s. Keep the registered-at- + creation rule and the FIFO one, which nothing else states, and drop "CI's ten-minute cap" — + that number lives in `.gitea/workflows/test.yaml`. Raised by the prose pass, 2026-09-20. + +- [ ] **Refuse a delay Node's timers cannot hold, in `checkLimits`.** `idleTimeout`, + `reassemblyTimeout`, `responseTimeout` and `shutdownTimeout` take any integer, and `setTimeout` + fires after 1 ms for anything above 2147483647 — so a value in the wrong unit gets the inverse + of what it asked for, explained only by a warning on stderr. `connectTimeout` refuses one + already, which is the asymmetry to close. The same four print an untyped value bare, so + `idleTimeout: '5000'` is refused with `got 5000` — a value the reader reads as correct — where + `connectTimeout` quotes it. `namedValue()`'s four sites — `messagingMode`, `encoding`, the time + options and `smsIdFormat` — are the same defect once more: there `true` and `'true'` both print + as `true`. One fix closes all three. Raised by review, 2026-09-20. - [ ] **A send the codec will refuse waits for a link and a window slot first.** `refuse()` in `outgoing-requests.ts` runs `misuse()` and the abort check before the wait, precisely so a call