Back off from a link that dies as soon as it comes up

A bind that returns is not proof the link works: an unreadable stream is
only found afterwards, so resetting the delay there handed a link that
died on arrival a fresh minDelay every cycle — one connect and bind per
second forever, which is how an account gets blocked for bind flooding.
The loop resets only once a link has outlasted maxDelay.

Also covers the unreadable stream that has no loop to retry it.
This commit is contained in:
2026-08-31 22:45:29 +02:00
parent b1c790b9a0
commit 8e4d472f72
6 changed files with 78 additions and 14 deletions
+4 -3
View File
@@ -344,7 +344,7 @@ describe('reconnect', () => {
test('reports a drop it will retry as disconnected, keeping close for the end', async t => {
const smpp = await startServer(t);
const { session } = await connect(t, smpp);
const { session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } });
assert.ok(session);
@@ -391,7 +391,7 @@ describe('reconnect', () => {
test('re-binds after a stream it cannot read, rather than ending the session', async t => {
const smpp = await startServer(t);
const { session } = await connect(t, smpp);
const { session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } });
assert.ok(session);
@@ -448,7 +448,8 @@ describe('reconnect', () => {
const closed = once<true>(resolve => { session.on('close', () => { resolve(true); }); });
await peerOf(smpp).close();
// A cmd_length below the 16-octet header: unreadable, and nothing left to retry it.
peerOf(smpp).sock.write(Buffer.from([0, 0, 0, 4, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 1]));
await closed;
assert.equal(disconnects, 0);
+47
View File
@@ -4,6 +4,7 @@ import test, { describe } from 'node:test';
import type { Dlr } from '../src/dlr.ts';
import type { PduObject, PduObjectInput } from '../src/pdu.ts';
import type { Sms } from '../src/sms.ts';
import type { SmppLog } from '../src/log.ts';
import type { SmppServer } from '../src/server.ts';
import type { TestContext } from 'node:test';
import type { VoidResult } from '../src/result.ts';
@@ -1372,6 +1373,52 @@ describe('application hooks that throw or reject', () => {
assert.ok(retried, 'a throwing connect should be retried, not left for the process to die on');
});
test('keeps backing off when every link dies as soon as it comes up', async t => {
// A stream we cannot read is found after the bind, so a bind alone must not prove the link.
const clock = { now: 0 };
const delays: number[] = [];
const noop = (): void => undefined;
const log: SmppLog = {
debug: noop,
error: noop,
info: (msg, metadata) => {
if (msg === 'reconnect - retrying after a drop') delays.push(Number(metadata?.delay));
},
verbose: noop,
warn: noop,
};
let up = 0;
const loop = new ReconnectLoop({
connect: () => Promise.resolve({ sock: new net.Socket() }),
log,
maxDelay: 80,
minDelay: 10,
now: () => clock.now,
onConnected: () => {
up++;
return Promise.resolve({});
},
});
t.after(() => { loop.stop(); });
for (let died = 0; died < 4; died++) {
loop.schedule();
await waitFor(() => up === died + 1);
await delay(5);
}
assert.deepEqual(delays, [10, 20, 40, 80]);
// A link that outlasted the longest wait earned a fresh start.
clock.now += 80;
loop.schedule();
await waitFor(() => delays.length === 5);
assert.deepEqual(delays, [10, 20, 40, 80, 10]);
});
test('starts only one reconnect attempt at a time', async t => {
let attempts = 0;
let finish: (() => void) | undefined;