Give up a connect after connectTimeout instead of waiting the OS out #12

Merged
lilleman merged 7 commits from connect-timeout into main 2026-09-20 20:58:14 +02:00
10 changed files with 260 additions and 40 deletions
+7 -2
View File
@@ -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, `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. and a field added to that type fails to compile in every copy at once.
- `message_id` values the library generates are UUID v7. - `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 - A socket a test opens and never reads must be `resume()`d, and a `data` listener counts. An unread
peer's FIN, so `server.close()` hangs forever — that is a test bug, not a library one. 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 - 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 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 `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 - **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 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. 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 - **MIGRATION.md — what a 0.4.0 consumer has to change.** Renamed and removed surface, and the
behaviour that changed on the wire. behaviour that changed on the wire.
- **AGENTS.md — what may not change, and why.** Hard rules, architecture, conventions, and an index - **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. - 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 - `reconnect: { fromStart: true }` puts the first connect and bind through that same loop, and
`client()` then resolves only once it is bound. `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 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. - 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 - Every segment of a concatenated message is answered as it arrives, so `sendResp()` on one is the
+13
View File
@@ -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).
+5
View File
@@ -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. | | `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. | | `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. | | `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. | | `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`. | | `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. | | `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. | | 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`. | | 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 ## Migrating from larvitsmpp 0.4.0
See [MIGRATION.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/MIGRATION.md). See [MIGRATION.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/MIGRATION.md).
+20
View File
@@ -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 `LinkGate`'s hold is not — it is awaited with no other handle, so a process whose only work is
`client()` would exit unbound. `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.** - **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 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 16 or above `maxPduLength` leaves nothing that can say where the next PDU starts, so it tears the
+30 -2
View File
@@ -22,6 +22,7 @@ export type ClientOptions = {
addrNpi?: number; addrNpi?: number;
addrTon?: number; addrTon?: number;
bindType?: BindType; bindType?: BindType;
connectTimeout?: number | false;
enquireLinkInterval?: number; enquireLinkInterval?: number;
host?: string; host?: string;
idleTimeout?: number; idleTimeout?: number;
@@ -42,6 +43,7 @@ export type ClientOptions = {
const defaults = { const defaults = {
bindType: 'transceiver', bindType: 'transceiver',
connectTimeout: 10_000,
enquireLinkInterval: 20_000, enquireLinkInterval: 20_000,
host: 'localhost', host: 'localhost',
/** The idle timeout is what notices a dead link, so it has to outlast one silent probe. */ /** 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', username: 'user',
} as const; } 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<Result<{ sock: Socket }>> { function openSocket(options: ClientOptions): Promise<Result<{ sock: Socket }>> {
const connectTimeout = options.connectTimeout ?? defaults.connectTimeout;
const host = options.host ?? defaults.host; const host = options.host ?? defaults.host;
const port = options.port ?? defaults.port; const port = options.port ?? defaults.port;
const secure = options.tls !== undefined && options.tls !== false; const secure = options.tls !== undefined && options.tls !== false;
@@ -77,11 +102,14 @@ function openSocket(options: ClientOptions): Promise<Result<{ sock: Socket }>> {
return; 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); sock.removeListener('error', onError);
signal?.removeEventListener('abort', onAbort); signal?.removeEventListener('abort', onAbort);
resolve(result); resolve(result);
}; }
function onError(err: Error): void { function onError(err: Error): void {
settle({ err }); settle({ err });
+4 -2
View File
@@ -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 { 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;
} }
+35 -8
View File
@@ -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 }') }; return { err: new Error('fromStart is part of the reconnect policy, spell it reconnect: { fromStart: true }') };
} }
const checked = checkLimits([ const connect = checkConnectTimeout(options.connectTimeout);
['idleTimeout', options.idleTimeout ?? 0, 0],
['maxOutstanding', options.maxOutstanding ?? defaults.maxOutstanding, 1], if (connect.err) return connect;
['maxReassembly', options.maxReassembly ?? defaults.maxReassembly, 1],
['reassemblyTimeout', options.reassemblyTimeout ?? defaults.reassemblyTimeout, 0], const checked = checkLimits(limitsOf(options));
['responseTimeout', options.responseTimeout ?? defaults.responseTimeout, 0],
['shutdownTimeout', options.shutdownTimeout ?? defaults.shutdownTimeout, 0],
]);
if (checked.err) return checked; if (checked.err) return checked;
@@ -160,6 +157,35 @@ export function checkSessionOptions(options: CheckableOptions): VoidResult {
return backoff.err ? backoff : checkSmsIdFormat(options.smsIdFormat); 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 { function checkLimits(limits: [string, number, number][]): VoidResult {
for (const [name, value, min] of limits) { for (const [name, value, min] of limits) {
if (!Number.isInteger(value) || value < min) { 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. */ /** What the checker reads, as it arrives: a caller without types can put anything in it. */
export type CheckableOptions = { export type CheckableOptions = {
connectTimeout?: unknown;
/** Not an option: the one spelling is inside reconnect, and this is where the other is refused. */ /** Not an option: the one spelling is inside reconnect, and this is where the other is refused. */
fromStart?: unknown; fromStart?: unknown;
idleTimeout?: number | undefined; idleTimeout?: number | undefined;
+130 -15
View File
@@ -84,6 +84,21 @@ function within<T>(ms: number, promise: Promise<T>): Promise<T | undefined> {
return Promise.race([promise, delay(ms).then((): undefined => undefined)]); 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<typeof client>,
): 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 { function submitPdu(seqNr: number, cmdStatus: ErrorName = 'ESME_ROK'): PduObject {
return { return {
cmdId: 0x00000004, cmdId: 0x00000004,
@@ -798,21 +813,6 @@ describe('reconnect from the first bind', () => {
return port; return port;
} }
/** A client still retrying holds a socket and a timer nothing else releases. */
function abortAfter(
t: TestContext,
controller: AbortController,
connecting: ReturnType<typeof client>,
): 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 () => { test('gives up on the first attempt where reconnect alone is asked for', async () => {
const port = await closedPort(); const port = await closedPort();
const spy = logSpy(); 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<void>(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', () => { describe('sends across a reconnect', () => {
/** Answers every message after the first, which is left to hold the send window open. */ /** Answers every message after the first, which is left to hold the send window open. */
function answerAfterTheFirst(smpp: SmppServer, arrived: string[]): Latch { function answerAfterTheFirst(smpp: SmppServer, arrived: string[]): Latch {
-1
View File
@@ -1493,7 +1493,6 @@ describe('robustness', () => {
// 0.4.0 registered a listener per sequence number and waited forever, leaking one per call. // 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 => { test('gives up on a peer that never answers', async t => {
const accepted: net.Socket[] = []; 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(); }); const silent = net.createServer(sock => { accepted.push(sock); sock.resume(); });
await new Promise<void>(resolve => silent.listen(0, resolve)); await new Promise<void>(resolve => silent.listen(0, resolve));
+16 -10
View File
@@ -179,16 +179,22 @@ the rewrite, for a dependency added later. Maintainer's call, 2026-09-14.
## Worth doing, not blocking ## Worth doing, not blocking
- [ ] **Give up a connect after `connectTimeout` instead of waiting the OS out.** `openSocket()` in - [ ] **Cut the three teardown sentences `test/teardown.ts` already says.** Under AGENTS.md's
`client.ts` settles only on `connect`/`secureConnect`, a socket `error`, or the caller's Conventions, "`test/teardown.ts` covers a session, a server and a listener" restates its two
`signal`, and `reconnect-loop.ts` times nothing but its backoff — so a host that drops SYNs exported names, "Its close aborts rather than drains" restates `closeAfter`'s own doc comment,
stalls every attempt for the OS TCP timeout, around 130 s on Linux at the default and the `net.Server.close()` sentence restates `closeListenerAfter`'s. Keep the registered-at-
`tcp_syn_retries`, and `reconnect.fromStart` cannot retry what never returns. The option aborts creation rule and the FIFO one, which nothing else states, and drop "CI's ten-minute cap" —
the socket and returns the ordinary connect failure; absent, the wait stays exactly 0.5.0's, so that number lives in `.gitea/workflows/test.yaml`. Raised by the prose pass, 2026-09-20.
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 — - [ ] **Refuse a delay Node's timers cannot hold, in `checkLimits`.** `idleTimeout`,
[SwiftHero/larvitsmpp](https://github.com/SwiftHero/larvitsmpp) added a 10 s connect timeout in `reassemblyTimeout`, `responseTimeout` and `shutdownTimeout` take any integer, and `setTimeout`
2017, and it is the one fork change with no equivalent here. 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 - [ ] **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 `outgoing-requests.ts` runs `misuse()` and the abort check before the wait, precisely so a call