Retry the first connect and bind on reconnect fromStart (#80)

* Regression tests for a client that retries its first connect and bind

* Retry the first connect and bind on reconnect: { fromStart: true }

* Pin that the signal bounding the first bind also closes the session

* Name a failed connect once, default the backoff where it lives, and fix the deadline advice

* Carry the failure that started the retries on the abort that ends them

* Pin one retry announcement per attempt when a link drops mid-bind

* Announce a retry when its wait is over, not when it is scheduled

* Say what the process-hold assertion expects, not what a failure would mean
This commit is contained in:
2026-09-05 22:47:06 +02:00
committed by GitHub
parent 45d2f5548e
commit afe188eecd
8 changed files with 467 additions and 56 deletions
+267 -1
View File
@@ -74,6 +74,11 @@ function delay(ms: number): Promise<void> {
return new Promise(resolve => { setTimeout(resolve, ms); });
}
/** Undefined where the promise never settled, which is an assertion rather than a hung run. */
function within<T>(ms: number, promise: Promise<T>): Promise<T | undefined> {
return Promise.race([promise, delay(ms).then((): undefined => undefined)]);
}
function submitPdu(seqNr: number, cmdStatus: ErrorName = 'ESME_ROK'): PduObject {
return {
cmdId: 0x00000004,
@@ -547,7 +552,7 @@ describe('reconnect', () => {
await closed;
assert.equal(disconnects, 0);
assert.ok(!infos.includes('reconnect - retrying after a drop'));
assert.ok(!infos.includes('reconnect - retrying'));
});
test('re-binds after the connection drops, keeping the same session object', async t => {
@@ -732,6 +737,267 @@ describe('reconnect', () => {
});
});
describe('reconnect from the first bind', () => {
type LogSpy = { delays: number[]; log: SmppLog; messages: string[] };
/** The backoff is only visible in the log, which is where the loop announces each wait. */
function logSpy(): LogSpy {
const delays: number[] = [];
const messages: string[] = [];
const noop = (): void => undefined;
const record = (msg: string): void => { messages.push(msg); };
return {
delays,
log: {
debug: noop,
error: record,
info: (msg, metadata) => {
messages.push(msg);
if (msg === 'reconnect - retrying') delays.push(Number(metadata?.delay));
},
verbose: noop,
warn: record,
},
messages,
};
}
/** A port nothing answers on: taken and given straight back. */
async function closedPort(): Promise<number> {
const listener = net.createServer();
await new Promise<void>(resolve => { listener.listen(0, resolve); });
const address = listener.address();
const port = typeof address === 'object' && address !== null ? address.port : 0;
await new Promise<void>(resolve => { listener.close(() => { resolve(); }); });
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 () => {
const port = await closedPort();
const spy = logSpy();
const started = Date.now();
const { err, session } = await client({ log: spy.log, port, reconnect: { maxDelay: 40, minDelay: 10 } });
assert.ok(err instanceof Error);
assert.equal(session, undefined);
assert.ok(Date.now() - started < 1000, 'the default answers the caller rather than retrying');
assert.ok(!spy.messages.includes('reconnect - retrying'), 'nothing may retry what the default gave up on');
});
test('retries the first connect with backoff and binds once the SMSC listens', async t => {
const port = await closedPort();
const spy = logSpy();
const controller = new AbortController();
const connecting = client({
log: spy.log,
port,
reconnect: { fromStart: true, maxDelay: 40, minDelay: 10 },
signal: controller.signal,
});
abortAfter(t, controller, connecting);
await delay(200);
const listening = await server({ port });
assert.equal(listening.err, undefined);
assert.ok(listening.server);
closeAfter(t, listening.server);
const { err, session } = await connecting;
assert.equal(err, undefined);
assert.ok(session);
assert.equal(session.loggedIn, true);
assert.ok(spy.delays.length >= 3, 'the SMSC was down for several attempts');
assert.deepEqual(spy.delays.slice(0, 3), [10, 20, 40], 'each wait doubles, up to maxDelay');
assert.ok(
!spy.messages.includes('session - reconnected'),
'a first link is not a link coming back',
);
});
// Bind flooding is what the backoff bounds; credentials an SMSC refuses now it may take later.
test('retries a bind the SMSC refuses rather than giving up on it', async t => {
let binds = 0;
const spy = logSpy();
const smpp = await startServer(t, { authenticate: () => ++binds > 2 });
const controller = new AbortController();
const connecting = client({
log: spy.log,
port: smpp.port,
reconnect: { fromStart: true, maxDelay: 40, minDelay: 10 },
signal: controller.signal,
});
abortAfter(t, controller, connecting);
const { err, session } = await connecting;
assert.equal(err, undefined);
assert.ok(session);
assert.equal(binds, 3, 'the two refusals were retried, not reported');
assert.deepEqual(spy.delays, [10, 20], 'a link the SMSC refused a bind on does not reset the backoff');
});
// A drop while the bind is in flight is where the attempt's own session could retry as well.
test('retries once per attempt when the peer drops the link mid-bind', async t => {
let binds = 0;
const spy = logSpy();
const smpp = await startServer(t, {
authenticate: input => {
binds++;
if (binds > 2) return true;
input.session.sock.destroy();
return false;
},
});
const controller = new AbortController();
const connecting = client({
log: spy.log,
port: smpp.port,
reconnect: { fromStart: true, maxDelay: 40, minDelay: 10 },
signal: controller.signal,
});
abortAfter(t, controller, connecting);
const { err, session } = await connecting;
assert.equal(err, undefined);
assert.ok(session);
assert.deepEqual(spy.delays, [10, 20], 'only the loop that owns the retry announces one');
});
test('hands back a session that reported nothing and reconnects like any other', async t => {
const port = await closedPort();
const controller = new AbortController();
const connecting = client({
port,
reconnect: { fromStart: true, maxDelay: 40, minDelay: 10 },
signal: controller.signal,
});
abortAfter(t, controller, connecting);
await delay(100);
const listening = await server({ port });
assert.equal(listening.err, undefined);
assert.ok(listening.server);
closeAfter(t, listening.server);
const { session } = await connecting;
assert.ok(session);
const events: string[] = [];
session.on('close', () => { events.push('close'); });
session.on('disconnected', () => { events.push('disconnected'); });
const reconnected = once<true>(resolve => { session.on('reconnected', () => { resolve(true); }); });
await peerOf(listening.server).close();
await reconnected;
assert.deepEqual(events, ['disconnected'], 'the attempts before the first link reported nothing');
const closed = once<true>(resolve => { session.on('close', () => { resolve(true); }); });
controller.abort();
await closed;
// Which is why a deadline for the wait may not be a signal that goes on arming afterwards.
assert.deepEqual(events, ['disconnected', 'close'], 'the signal that bounded the wait ends the session');
});
test('lets an abort out of the initial retries', async () => {
const port = await closedPort();
const controller = new AbortController();
const connecting = client({
port,
reconnect: { fromStart: true, maxDelay: 40, minDelay: 10 },
signal: controller.signal,
});
await delay(60);
controller.abort();
const settled = await within(2000, connecting);
assert.ok(settled, 'an aborted signal is the way out of a wait nothing else ends');
assert.ok(settled.err instanceof Error);
assert.ok(settled.err.cause instanceof Error, 'an abort carries what the attempts kept failing with');
assert.equal(settled.session, undefined);
});
test('holds the process open between the initial attempts', async t => {
const port = await closedPort();
const controller = new AbortController();
const timeouts = (): number => process.getActiveResourcesInfo().filter(name => name === 'Timeout').length;
const before = timeouts();
const connecting = client({
port,
reconnect: { fromStart: true, maxDelay: 5000, minDelay: 5000 },
signal: controller.signal,
});
abortAfter(t, controller, connecting);
await delay(200);
assert.ok(timeouts() > before, 'the wait should hold a process whose only work is this client');
controller.abort();
const settled = await within(2000, connecting);
assert.ok(settled);
assert.ok(settled.err instanceof Error);
});
test('refuses fromStart spelled anywhere but inside reconnect', async () => {
const misspelled = { fromStart: true, port: 1, reconnect: false } as const;
assert.match(
checkSessionOptions(misspelled).err?.message ?? '',
/reconnect: \{ fromStart: true \}/,
'off and from-start contradict each other, so the one spelling has to be named',
);
const refused = await client(misspelled);
assert.ok(refused.err instanceof Error);
assert.equal(refused.session, undefined);
assert.match(checkSessionOptions({ reconnect: { fromStart: 'yes' } }).err?.message ?? '', /fromStart/);
assert.equal(checkSessionOptions({ reconnect: { fromStart: true, minDelay: 10 } }).err, 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 {
+1 -1
View File
@@ -1638,7 +1638,7 @@ describe('application hooks that throw or reject', () => {
debug: noop,
error: noop,
info: (msg, metadata) => {
if (msg === 'reconnect - retrying after a drop') delays.push(Number(metadata?.delay));
if (msg === 'reconnect - retrying') delays.push(Number(metadata?.delay));
},
verbose: noop,
warn: noop,