From 17447bbe3a23e40365c83c57700e56fd2bf1f211 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 20 Sep 2026 00:42:09 +0200 Subject: [PATCH 1/7] Give up a connect after connectTimeout instead of waiting the OS out --- AGENTS.md | 4 ++ CHANGELOG.md | 9 +++ README.md | 1 + docs/decisions.md | 13 +++++ src/client.ts | 20 +++++++ src/session-options.ts | 36 +++++++++--- test/session-extras.test.ts | 110 +++++++++++++++++++++++++++++++----- todo.md | 15 ++--- 8 files changed, 175 insertions(+), 33 deletions(-) create mode 100644 CHANGELOG.md diff --git a/AGENTS.md b/AGENTS.md index 3a7c258..25783ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -214,6 +214,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 whoever takes the package + off npm, 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 +301,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` is absent by default, bounds the whole connect including the TLS handshake, and + `0` is refused. - 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..dd08c4f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +What changed for someone using `@larvit/smpp`, newest first. + +## Unreleased + +- `client()` takes `connectTimeout`, which gives up on a connect the SMSC never completes — the TLS + handshake included — and reports it as an ordinary connect failure, so `reconnect` retries it on + its usual backoff. Leave it out and the wait is the operating system's, as before. diff --git a/README.md b/README.md index f29ff31..fe67fac 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` | — | Give up on a connect the SMSC never completes, the TLS handshake included, and report it as an ordinary connect failure, which `reconnect` then retries. Absent, the wait is the operating system's, around 130 s on Linux. | | `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. | diff --git a/docs/decisions.md b/docs/decisions.md index df1797b..9596801 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -524,6 +524,19 @@ 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` is absent by default, bounds the whole connect including the TLS handshake, and + `0` is refused.** Maintainer's call, 2026-09-20, serving goal 6: 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. Absent is how that wait stays + the operating system's, so a call passing no options is unchanged, and `0` would be a second + spelling for absent — refused at the call, naming the spelling that turns it off. What expires is + reported as the ordinary connect failure, so the loop retries it like any other. 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: `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. Open while it has no default: + whether a later major gives it one. + - **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..1f52457 100644 --- a/src/client.ts +++ b/src/client.ts @@ -22,6 +22,7 @@ export type ClientOptions = { addrNpi?: number; addrTon?: number; bindType?: BindType; + connectTimeout?: number; enquireLinkInterval?: number; host?: string; idleTimeout?: number; @@ -52,7 +53,22 @@ const defaults = { username: 'user', } as const; +function armConnectTimeout( + sock: Socket, + connectTimeout: number | undefined, + settle: (result: Result<{ sock: Socket }>) => void, +): NodeJS.Timeout | undefined { + if (connectTimeout === undefined) return undefined; + + // The connecting socket is what holds the process, so this wait never has to. + return setTimeout(() => { + sock.destroy(); + settle({ err: new Error(`Timed out connecting after ${String(connectTimeout)} ms`) }); + }, connectTimeout).unref(); +} + function openSocket(options: ClientOptions): Promise> { + const connectTimeout = options.connectTimeout; const host = options.host ?? defaults.host; const port = options.port ?? defaults.port; const secure = options.tls !== undefined && options.tls !== false; @@ -78,6 +94,8 @@ function openSocket(options: ClientOptions): Promise> { } const settle = (result: Result<{ sock: Socket }>): void => { + if (timer) clearTimeout(timer); + sock.removeListener('error', onError); signal?.removeEventListener('abort', onAbort); resolve(result); @@ -97,6 +115,8 @@ function openSocket(options: ClientOptions): Promise> { sock.once(secure ? 'secureConnect' : 'connect', () => { settle({ sock }); }); + + const timer = armConnectTimeout(sock, connectTimeout, settle); }); } diff --git a/src/session-options.ts b/src/session-options.ts index c085a22..23aed0f 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,28 @@ 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], + ]; +} + +/** Leaving it out is how the wait stays the OS's, so 0 would be a second spelling for that. */ +function checkConnectTimeout(connectTimeout: number | undefined): VoidResult { + if (connectTimeout === undefined) return {}; + + if (Number.isInteger(connectTimeout) && connectTimeout >= 1) return {}; + + const got = String(connectTimeout); + + return { err: new Error(`connectTimeout must be 1 or more, got ${got}; omit it to wait the OS out`) }; +} + function checkLimits(limits: [string, number, number][]): VoidResult { for (const [name, value, min] of limits) { if (!Number.isInteger(value) || value < min) { @@ -238,6 +257,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?: number | undefined; /** 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..eedd257 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,86 @@ 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); }); + + 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 started = Date.now(); + 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, /Timed out connecting/); + assert.equal(settled.session, undefined); + assert.ok(Date.now() - started >= 150, 'the timeout is what settles it, not a socket error'); + }); + + 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: 50 }); + + assert.ok(session); + await delay(150); + + 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 ?? '', + /omit it/, + 'off is spelled by leaving it out, so 0 may not stand in for it', + ); + assert.match(checkSessionOptions({ connectTimeout: -1 }).err?.message ?? '', /connectTimeout/); + assert.match(checkSessionOptions({ connectTimeout: 1.5 }).err?.message ?? '', /connectTimeout/); + assert.equal(checkSessionOptions({ connectTimeout: 1000 }).err, undefined); + + const refused = await client({ connectTimeout: 0, port: 1 }); + + assert.ok(refused.err instanceof Error); + assert.equal(refused.session, undefined); + }); +}); + 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/todo.md b/todo.md index c6f7324..fabfb18 100644 --- a/todo.md +++ b/todo.md @@ -179,16 +179,11 @@ 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. +- [ ] **Decide whether a later major gives `connectTimeout` a default.** The option ships absent, so + a call passing none waits the OS out as 0.5.0 did — the half the option deliberately left open. + A default is a breaking change for anyone relying on that wait, and the minor is the breaking + unit until 1.0.0. Needs a number an operator would recognise, where SwiftHero's fork picked + 10 s. - [ ] **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 -- 2.52.0 From 84174b4a5bceea7018d747c67856400a6161191a Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 20 Sep 2026 18:12:39 +0200 Subject: [PATCH 2/7] Refuse a connectTimeout Node's timers cannot hold, and pin the refusals the tests missed --- AGENTS.md | 4 ++-- docs/decisions.md | 5 +++-- src/client.ts | 3 +-- src/session-options.ts | 16 ++++++++++++---- test/session-extras.test.ts | 20 ++++++++++++++------ todo.md | 6 ++++++ 6 files changed, 38 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 25783ae..1f0829e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -214,8 +214,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 whoever takes the package - off npm, never for the next agent, and a line lands there as the work ships rather than at release. +- **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 diff --git a/docs/decisions.md b/docs/decisions.md index 9596801..b2bfec7 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -534,8 +534,9 @@ rule and an index of the titles below. `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: `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. Open while it has no default: - whether a later major gives it one. + 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. Open + while it has no default: whether a later major gives it one. - **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 diff --git a/src/client.ts b/src/client.ts index 1f52457..0894270 100644 --- a/src/client.ts +++ b/src/client.ts @@ -94,8 +94,7 @@ function openSocket(options: ClientOptions): Promise> { } const settle = (result: Result<{ sock: Socket }>): void => { - if (timer) clearTimeout(timer); - + clearTimeout(timer); sock.removeListener('error', onError); signal?.removeEventListener('abort', onAbort); resolve(result); diff --git a/src/session-options.ts b/src/session-options.ts index 23aed0f..46592c4 100644 --- a/src/session-options.ts +++ b/src/session-options.ts @@ -168,15 +168,23 @@ function limitsOf(options: CheckableOptions): [string, number, number][] { ]; } -/** Leaving it out is how the wait stays the OS's, so 0 would be a second spelling for that. */ +/** Node's timers are 32-bit, and a delay above this one fires after 1 ms instead of never. */ +const maxTimerDelay = 2_147_483_647; + function checkConnectTimeout(connectTimeout: number | undefined): VoidResult { if (connectTimeout === undefined) return {}; - if (Number.isInteger(connectTimeout) && connectTimeout >= 1) return {}; - const got = String(connectTimeout); - return { err: new Error(`connectTimeout must be 1 or more, got ${got}; omit it to wait the OS out`) }; + if (!Number.isInteger(connectTimeout) || connectTimeout < 1) { + return { err: new Error(`connectTimeout must be a whole number of milliseconds, 1 or more, got ${got}; omit it to wait the OS out`) }; + } + + if (connectTimeout > maxTimerDelay) { + return { err: new Error(`connectTimeout must be ${String(maxTimerDelay)} or less, got ${got}`) }; + } + + return {}; } function checkLimits(limits: [string, number, number][]): VoidResult { diff --git a/test/session-extras.test.ts b/test/session-extras.test.ts index eedd257..00cc60f 100644 --- a/test/session-extras.test.ts +++ b/test/session-extras.test.ts @@ -1022,7 +1022,10 @@ 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); }); + 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); }); @@ -1034,7 +1037,6 @@ describe('connectTimeout', () => { test('gives up on a connect the peer never completes', async t => { const { port } = await stalledListener(t); - const started = Date.now(); const settled = await within(2000, client({ connectTimeout: 150, host: '127.0.0.1', @@ -1047,7 +1049,6 @@ describe('connectTimeout', () => { assert.ok(settled.err instanceof Error); assert.match(settled.err.message, /Timed out connecting/); assert.equal(settled.session, undefined); - assert.ok(Date.now() - started >= 150, 'the timeout is what settles it, not a socket error'); }); test('retries a connect it timed out on, like any other failed attempt', async t => { @@ -1070,10 +1071,10 @@ describe('connectTimeout', () => { 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: 50 }); + const { session } = await connect(t, smpp, { connectTimeout: 200 }); assert.ok(session); - await delay(150); + await delay(300); const probe = await session.send({ cmdName: 'enquire_link' }); @@ -1088,12 +1089,19 @@ describe('connectTimeout', () => { 'off is spelled by leaving it out, so 0 may not stand in for it', ); assert.match(checkSessionOptions({ connectTimeout: -1 }).err?.message ?? '', /connectTimeout/); - assert.match(checkSessionOptions({ connectTimeout: 1.5 }).err?.message ?? '', /connectTimeout/); + assert.match(checkSessionOptions({ connectTimeout: 1.5 }).err?.message ?? '', /whole number/); + assert.match( + checkSessionOptions({ connectTimeout: 2_147_483_648 }).err?.message ?? '', + /2147483647 or less/, + '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, /omit it/, 'the socket may not be opened before the option is refused'); assert.equal(refused.session, undefined); }); }); diff --git a/todo.md b/todo.md index fabfb18..00875e1 100644 --- a/todo.md +++ b/todo.md @@ -185,6 +185,12 @@ the rewrite, for a dependency added later. Maintainer's call, 2026-09-14. unit until 1.0.0. Needs a number an operator would recognise, where SwiftHero's fork picked 10 s. +- [ ] **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. 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 that can never go out does not queue for what it will never use; a body `objToPdu()` refuses on -- 2.52.0 From 8f416c2bc413b5f05db433e51293c712e4fb4c93 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 20 Sep 2026 18:17:39 +0200 Subject: [PATCH 3/7] Name the peer and the stalled phase on a connect timeout, and quote an untyped value --- AGENTS.md | 5 +++-- CHANGELOG.md | 13 ++++++++----- README.md | 6 +++++- src/client.ts | 11 ++++++++--- src/session-options.ts | 13 ++++++------- test/session-extras.test.ts | 13 +++++++++++-- test/session.test.ts | 1 - todo.md | 7 +++++++ 8 files changed, 48 insertions(+), 21 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1f0829e..c68ebfc 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index dd08c4f..4e6c789 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,12 @@ # Changelog -What changed for someone using `@larvit/smpp`, newest first. - ## Unreleased -- `client()` takes `connectTimeout`, which gives up on a connect the SMSC never completes — the TLS - handshake included — and reports it as an ordinary connect failure, so `reconnect` retries it on - its usual backoff. Leave it out and the wait is the operating system's, as before. +- `client()` takes `connectTimeout`, which gives up on a connect attempt the SMSC never completes — + the TLS handshake included — and reports it as an ordinary connect failure, so `reconnect` retries + it on its usual backoff. Leave it out and each attempt waits the operating system out, as before. + +## 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 fe67fac..a618b30 100644 --- a/README.md +++ b/README.md @@ -227,7 +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` | — | Give up on a connect the SMSC never completes, the TLS handshake included, and report it as an ordinary connect failure, which `reconnect` then retries. Absent, the wait is the operating system's, around 130 s on Linux. | +| `connectTimeout` | — | 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 one attempt, never the `client()` call. Absent, each attempt waits the operating system out, around 130 s on Linux. | | `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. | @@ -630,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/src/client.ts b/src/client.ts index 0894270..48f6d0f 100644 --- a/src/client.ts +++ b/src/client.ts @@ -56,14 +56,19 @@ const defaults = { function armConnectTimeout( sock: Socket, connectTimeout: number | undefined, + peer: string, settle: (result: Result<{ sock: Socket }>) => void, ): NodeJS.Timeout | undefined { if (connectTimeout === undefined) return undefined; - // The connecting socket is what holds the process, so this wait never has to. + // A firewall and a stalled handshake need different answers, and only the phase tells them apart. + let phase = `connecting to ${peer}`; + + sock.once('connect', () => { phase = `completing the TLS handshake with ${peer}`; }); + return setTimeout(() => { sock.destroy(); - settle({ err: new Error(`Timed out connecting after ${String(connectTimeout)} ms`) }); + settle({ err: new Error(`Timed out ${phase} after ${String(connectTimeout)} ms`) }); }, connectTimeout).unref(); } @@ -115,7 +120,7 @@ function openSocket(options: ClientOptions): Promise> { settle({ sock }); }); - const timer = armConnectTimeout(sock, connectTimeout, settle); + const timer = armConnectTimeout(sock, connectTimeout, `${host}:${String(port)}`, settle); }); } diff --git a/src/session-options.ts b/src/session-options.ts index 46592c4..0d289ee 100644 --- a/src/session-options.ts +++ b/src/session-options.ts @@ -168,20 +168,19 @@ function limitsOf(options: CheckableOptions): [string, number, number][] { ]; } -/** Node's timers are 32-bit, and a delay above this one fires after 1 ms instead of never. */ const maxTimerDelay = 2_147_483_647; -function checkConnectTimeout(connectTimeout: number | undefined): VoidResult { +function checkConnectTimeout(connectTimeout: unknown): VoidResult { if (connectTimeout === undefined) return {}; - const got = String(connectTimeout); + const got = typeof connectTimeout === 'string' ? `"${connectTimeout}"` : namedValue(connectTimeout); - if (!Number.isInteger(connectTimeout) || connectTimeout < 1) { - return { err: new Error(`connectTimeout must be a whole number of milliseconds, 1 or more, got ${got}; omit it to wait the OS out`) }; + 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}; omit it or pass undefined to wait the OS out`) }; } if (connectTimeout > maxTimerDelay) { - return { err: new Error(`connectTimeout must be ${String(maxTimerDelay)} or less, got ${got}`) }; + return { err: new Error(`connectTimeout must be ${String(maxTimerDelay)} ms or less (about 24 days), got ${got}`) }; } return {}; @@ -265,7 +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?: number | undefined; + 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 00cc60f..f9a2d62 100644 --- a/test/session-extras.test.ts +++ b/test/session-extras.test.ts @@ -1047,7 +1047,11 @@ describe('connectTimeout', () => { 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, /Timed out connecting/); + assert.match( + settled.err.message, + new RegExp(`Timed out completing the TLS handshake with 127\\.0\\.0\\.1:${String(port)} after 150 ms`), + 'a firewall and a peer that accepts then stalls need different answers from the operator', + ); assert.equal(settled.session, undefined); }); @@ -1090,9 +1094,14 @@ describe('connectTimeout', () => { ); 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: 2_147_483_648 }).err?.message ?? '', - /2147483647 or less/, + /2147483647 ms or less/, '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); 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 00875e1..586a10b 100644 --- a/todo.md +++ b/todo.md @@ -185,6 +185,13 @@ the rewrite, for a dependency added later. Maintainer's call, 2026-09-14. unit until 1.0.0. Needs a number an operator would recognise, where SwiftHero's fork picked 10 s. +- [ ] **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 -- 2.52.0 From 1416615ca35b6b5af00762a45837e36400b45e9c Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 20 Sep 2026 20:34:53 +0200 Subject: [PATCH 4/7] Bound every connect attempt at 10 s by default, with false the way to opt out --- AGENTS.md | 4 ++-- CHANGELOG.md | 7 ++++--- README.md | 2 +- docs/decisions.md | 30 ++++++++++++++++++------------ src/client.ts | 11 ++++++----- src/session-options.ts | 4 ++-- test/session-extras.test.ts | 15 +++++++++++---- todo.md | 6 ------ 8 files changed, 44 insertions(+), 35 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c68ebfc..42b42d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -302,8 +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` is absent by default, bounds the whole connect including the TLS handshake, and - `0` is refused. +- `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 index 4e6c789..0eee28d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,10 @@ ## Unreleased -- `client()` takes `connectTimeout`, which gives up on a connect attempt the SMSC never completes — - the TLS handshake included — and reports it as an ordinary connect failure, so `reconnect` retries - it on its usual backoff. Leave it out and each attempt waits the operating system out, as before. +- `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 diff --git a/README.md b/README.md index a618b30..eb66a1d 100644 --- a/README.md +++ b/README.md @@ -227,7 +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` | — | 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 one attempt, never the `client()` call. Absent, each attempt waits the operating system out, around 130 s on Linux. | +| `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 one attempt, never the `client()` call. `false` waits the operating system out instead, around 130 s on Linux. | | `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. | diff --git a/docs/decisions.md b/docs/decisions.md index b2bfec7..05cce6a 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -524,19 +524,25 @@ 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` is absent by default, bounds the whole connect including the TLS handshake, and - `0` is refused.** Maintainer's call, 2026-09-20, serving goal 6: 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. Absent is how that wait stays - the operating system's, so a call passing no options is unchanged, and `0` would be a second - spelling for absent — refused at the call, naming the spelling that turns it off. What expires is - reported as the ordinary connect failure, so the loop retries it like any other. It settles on +- **`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: `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. Open - while it has no default: whether a later major gives it one. + 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 diff --git a/src/client.ts b/src/client.ts index 48f6d0f..ab61ff9 100644 --- a/src/client.ts +++ b/src/client.ts @@ -22,7 +22,7 @@ export type ClientOptions = { addrNpi?: number; addrTon?: number; bindType?: BindType; - connectTimeout?: number; + connectTimeout?: number | false; enquireLinkInterval?: number; host?: string; idleTimeout?: number; @@ -41,8 +41,9 @@ export type ClientOptions = { username?: string; }; -const defaults = { +export 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. */ @@ -55,11 +56,11 @@ const defaults = { function armConnectTimeout( sock: Socket, - connectTimeout: number | undefined, + connectTimeout: number | false, peer: string, settle: (result: Result<{ sock: Socket }>) => void, ): NodeJS.Timeout | undefined { - if (connectTimeout === undefined) return undefined; + if (connectTimeout === false) return undefined; // A firewall and a stalled handshake need different answers, and only the phase tells them apart. let phase = `connecting to ${peer}`; @@ -73,7 +74,7 @@ function armConnectTimeout( } function openSocket(options: ClientOptions): Promise> { - const connectTimeout = options.connectTimeout; + 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; diff --git a/src/session-options.ts b/src/session-options.ts index 0d289ee..01b7372 100644 --- a/src/session-options.ts +++ b/src/session-options.ts @@ -171,12 +171,12 @@ function limitsOf(options: CheckableOptions): [string, number, number][] { const maxTimerDelay = 2_147_483_647; function checkConnectTimeout(connectTimeout: unknown): VoidResult { - if (connectTimeout === undefined) return {}; + 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}; omit it or pass undefined to wait the OS out`) }; + 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) { diff --git a/test/session-extras.test.ts b/test/session-extras.test.ts index f9a2d62..2f54f77 100644 --- a/test/session-extras.test.ts +++ b/test/session-extras.test.ts @@ -25,7 +25,7 @@ import { DlrMerger } from '../src/dlr-merger.ts'; import { PduRefusedError } from '../src/pdu-refusal.ts'; import { objToPdu } from '../src/pdu.ts'; import { checkSessionOptions, standsInFor } from '../src/session-options.ts'; -import { client } from '../src/client.ts'; +import { client, defaults as clientDefaults } from '../src/client.ts'; import { closeAfter, closeListenerAfter } from './teardown.ts'; import { concatOf } from '../src/concat.ts'; import { consts } from '../src/defs/constants.ts'; @@ -1087,11 +1087,13 @@ describe('connectTimeout', () => { }); test('refuses a connect timeout that would turn itself off', async () => { + assert.equal(clientDefaults.connectTimeout, 10_000, 'the number the README documents'); assert.match( checkSessionOptions({ connectTimeout: 0 }).err?.message ?? '', - /omit it/, - 'off is spelled by leaving it out, so 0 may not stand in for it', + /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( @@ -1110,8 +1112,13 @@ describe('connectTimeout', () => { const refused = await client({ connectTimeout: 0, port: 1 }); assert.ok(refused.err instanceof Error); - assert.match(refused.err.message, /omit it/, 'the socket may not be opened before the option is refused'); + 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'); }); }); diff --git a/todo.md b/todo.md index 586a10b..3de7bbf 100644 --- a/todo.md +++ b/todo.md @@ -179,12 +179,6 @@ the rewrite, for a dependency added later. Maintainer's call, 2026-09-14. ## Worth doing, not blocking -- [ ] **Decide whether a later major gives `connectTimeout` a default.** The option ships absent, so - a call passing none waits the OS out as 0.5.0 did — the half the option deliberately left open. - A default is a breaking change for anyone relying on that wait, and the minor is the breaking - unit until 1.0.0. Needs a number an operator would recognise, where SwiftHero's fork picked - 10 s. - - [ ] **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, -- 2.52.0 From a24f350b7a4b7574eee0343febbc2b446da6b26e Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 20 Sep 2026 20:41:45 +0200 Subject: [PATCH 5/7] Prove the default bound is wired, and name the option on the failure a consumer reads --- CHANGELOG.md | 2 +- README.md | 2 +- src/client.ts | 23 +++++++++++++---------- src/error-from.ts | 6 ++++-- src/session-options.ts | 2 +- test/session-extras.test.ts | 30 ++++++++++++++++++++++++++---- todo.md | 4 +++- 7 files changed, 49 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0eee28d..6dc91e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 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. diff --git a/README.md b/README.md index eb66a1d..3cf199d 100644 --- a/README.md +++ b/README.md @@ -227,7 +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 one attempt, never the `client()` call. `false` waits the operating system out instead, around 130 s on Linux. | +| `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. | diff --git a/src/client.ts b/src/client.ts index ab61ff9..f20e831 100644 --- a/src/client.ts +++ b/src/client.ts @@ -41,7 +41,7 @@ export type ClientOptions = { username?: string; }; -export const defaults = { +const defaults = { bindType: 'transceiver', connectTimeout: 10_000, enquireLinkInterval: 20_000, @@ -57,19 +57,22 @@ export const defaults = { function armConnectTimeout( sock: Socket, connectTimeout: number | false, - peer: string, + target: { peer: string; secure: boolean }, settle: (result: Result<{ sock: Socket }>) => void, ): NodeJS.Timeout | undefined { if (connectTimeout === false) return undefined; - // A firewall and a stalled handshake need different answers, and only the phase tells them apart. - let phase = `connecting to ${peer}`; + let phase = `connecting to ${target.peer}`; - sock.once('connect', () => { phase = `completing the TLS handshake with ${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`) }); + settle({ + err: new Error(`Timed out ${phase} after ${String(connectTimeout)} ms; raise connectTimeout or set it to false`), + }); }, connectTimeout).unref(); } @@ -99,12 +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 }); @@ -120,8 +125,6 @@ function openSocket(options: ClientOptions): Promise> { sock.once(secure ? 'secureConnect' : 'connect', () => { settle({ sock }); }); - - const timer = armConnectTimeout(sock, connectTimeout, `${host}:${String(port)}`, settle); }); } diff --git a/src/error-from.ts b/src/error-from.ts index b2ccbd4..1ae1f4b 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 or a symbol, so those are named by type instead. */ 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 01b7372..fd53842 100644 --- a/src/session-options.ts +++ b/src/session-options.ts @@ -180,7 +180,7 @@ function checkConnectTimeout(connectTimeout: unknown): VoidResult { } if (connectTimeout > maxTimerDelay) { - return { err: new Error(`connectTimeout must be ${String(maxTimerDelay)} ms or less (about 24 days), got ${got}`) }; + return { err: new Error(`connectTimeout must be ${String(maxTimerDelay)} ms or less (about 24 days), got ${got}; false waits the OS out instead`) }; } return {}; diff --git a/test/session-extras.test.ts b/test/session-extras.test.ts index 2f54f77..d9e33ee 100644 --- a/test/session-extras.test.ts +++ b/test/session-extras.test.ts @@ -25,7 +25,7 @@ import { DlrMerger } from '../src/dlr-merger.ts'; import { PduRefusedError } from '../src/pdu-refusal.ts'; import { objToPdu } from '../src/pdu.ts'; import { checkSessionOptions, standsInFor } from '../src/session-options.ts'; -import { client, defaults as clientDefaults } from '../src/client.ts'; +import { client } from '../src/client.ts'; import { closeAfter, closeListenerAfter } from './teardown.ts'; import { concatOf } from '../src/concat.ts'; import { consts } from '../src/defs/constants.ts'; @@ -1049,12 +1049,35 @@ describe('connectTimeout', () => { 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`), - 'a firewall and a peer that accepts then stalls need different answers from the operator', + 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); }); + // The fallback is what every call that names no timeout gets, and localhost settles too fast to see it. + test('bounds a connect nobody asked to bound, at the default', async t => { + const { accepted, port } = await stalledListener(t); + + t.mock.timers.enable({ apis: ['setTimeout'] }); + + const connecting = client({ host: '127.0.0.1', port, reconnect: false, tls: true }); + + while (accepted.length === 0) { + await new Promise(resolve => setImmediate(resolve)); + } + + await new Promise(resolve => setImmediate(resolve)); + t.mock.timers.tick(10_000); + t.mock.timers.reset(); + + const settled = await within(2000, 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(); @@ -1087,7 +1110,6 @@ describe('connectTimeout', () => { }); test('refuses a connect timeout that would turn itself off', async () => { - assert.equal(clientDefaults.connectTimeout, 10_000, 'the number the README documents'); assert.match( checkSessionOptions({ connectTimeout: 0 }).err?.message ?? '', /false waits the OS out/, diff --git a/todo.md b/todo.md index 3de7bbf..5c119c8 100644 --- a/todo.md +++ b/todo.md @@ -190,7 +190,9 @@ the rewrite, for a dependency added later. Maintainer's call, 2026-09-14. `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. Raised by review, 2026-09-20. + 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. 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 -- 2.52.0 From 45171c2697f2918414aca295b592e892c35a3331 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 20 Sep 2026 20:46:45 +0200 Subject: [PATCH 6/7] Bound the wait in the default test, and pin the boolean a refusal prints --- src/error-from.ts | 2 +- test/session-extras.test.ts | 11 +++-------- todo.md | 4 +++- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/error-from.ts b/src/error-from.ts index 1ae1f4b..5d77236 100644 --- a/src/error-from.ts +++ b/src/error-from.ts @@ -11,7 +11,7 @@ export function errorFrom(reason: unknown): Error { const printable: readonly string[] = ['boolean', 'number', 'string']; -/** String() throws on a null-prototype object or a symbol, so those are named by type instead. */ +/** String() throws on a null-prototype object, so anything but these is named by its type. */ export function namedValue(value: unknown): string { return printable.includes(typeof value) ? String(value) : typeof value; } diff --git a/test/session-extras.test.ts b/test/session-extras.test.ts index d9e33ee..59522c0 100644 --- a/test/session-extras.test.ts +++ b/test/session-extras.test.ts @@ -1055,19 +1055,13 @@ describe('connectTimeout', () => { assert.equal(settled.session, undefined); }); - // The fallback is what every call that names no timeout gets, and localhost settles too fast to see it. test('bounds a connect nobody asked to bound, at the default', async t => { - const { accepted, port } = await stalledListener(t); + const { port } = await stalledListener(t); t.mock.timers.enable({ apis: ['setTimeout'] }); const connecting = client({ host: '127.0.0.1', port, reconnect: false, tls: true }); - while (accepted.length === 0) { - await new Promise(resolve => setImmediate(resolve)); - } - - await new Promise(resolve => setImmediate(resolve)); t.mock.timers.tick(10_000); t.mock.timers.reset(); @@ -1123,9 +1117,10 @@ describe('connectTimeout', () => { /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/, + /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); diff --git a/todo.md b/todo.md index 5c119c8..108933e 100644 --- a/todo.md +++ b/todo.md @@ -192,7 +192,9 @@ the rewrite, for a dependency added later. Maintainer's call, 2026-09-14. 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. Raised by review, 2026-09-20. + `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 -- 2.52.0 From 92fbd6a9f26c6d944d5c526137abe17f4507a857 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 20 Sep 2026 20:52:32 +0200 Subject: [PATCH 7/7] Wait the default out for real, since node:test mock timers postdate the Node 18 floor --- test/session-extras.test.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/test/session-extras.test.ts b/test/session-extras.test.ts index 59522c0..36b0bdb 100644 --- a/test/session-extras.test.ts +++ b/test/session-extras.test.ts @@ -1055,17 +1055,11 @@ describe('connectTimeout', () => { assert.equal(settled.session, undefined); }); - test('bounds a connect nobody asked to bound, at the default', async t => { + // 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); - - t.mock.timers.enable({ apis: ['setTimeout'] }); - const connecting = client({ host: '127.0.0.1', port, reconnect: false, tls: true }); - - t.mock.timers.tick(10_000); - t.mock.timers.reset(); - - const settled = await within(2000, connecting); + 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); -- 2.52.0