Count every request that went out unanswered, and gate a send on a bound link

This commit is contained in:
2026-09-01 10:08:56 +02:00
parent 8d9656b4e0
commit 35dd1e7678
7 changed files with 247 additions and 74 deletions
+114 -6
View File
@@ -12,6 +12,7 @@ import type { SmppLog } from '../src/log.ts';
import type { Sms } from '../src/sms.ts';
import type { SmppServer } from '../src/server.ts';
import type { TestContext } from 'node:test';
import { LinkGate } from '../src/link-gate.ts';
import { Reassembler, decodeSegments } from '../src/reassembly.ts';
import { Session } from '../src/session.ts';
import { DlrMerger } from '../src/dlr-merger.ts';
@@ -100,10 +101,10 @@ async function sendReceipt(peer: Session, smsId: string, tlvSmsId = smsId): Prom
assert.equal(sent.err, undefined);
}
type Gate = { open: () => void; passed: Promise<true> };
type Latch = { open: () => void; passed: Promise<true> };
/** A promise the test opens by hand, guarded by once() against waiting on one it never does. */
function gate(): Gate {
function latch(): Latch {
const opener: { open?: () => void } = {};
const passed = once<true>(resolve => { opener.open = () => { resolve(true); }; });
@@ -559,8 +560,8 @@ describe('reconnect', () => {
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[]): Gate {
const first = gate();
function answerAfterTheFirst(smpp: SmppServer, arrived: string[]): Latch {
const first = latch();
smpp.on('session', peer => {
peer.on('sms', async sms => {
@@ -624,6 +625,87 @@ describe('sends across a reconnect', () => {
assert.deepEqual(arrived, ['first', 'second']);
});
test('holds a send issued while the rebind is still binding', async t => {
const binding = latch();
const release = latch();
let binds = 0;
const smpp = await startServer(t, {
authenticate: async () => {
binds++;
if (binds > 1) {
binding.open();
await release.passed;
}
return true;
},
});
smpp.on('session', peer => { peer.on('sms', async sms => { await sms.sendResp(); }); });
const { session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } });
assert.ok(session);
peerOf(smpp).sock.destroy();
// The fresh socket is attached and the bind is in flight, so the link exists but carries nothing.
await binding.passed;
const sending = session.sendSms({ from: '46701113311', message: 'mid-bind', to: '46709771337' });
let settled = false;
void sending.then(() => { settled = true; });
await delay(30);
assert.equal(settled, false, 'an unbound link must not take a submit_sm that would be refused');
release.open();
const sent = await sending;
assert.equal(sent.err, undefined);
assert.equal(sent.smsIds.length, 1);
});
test('counts a segment the peer never answered in time as unanswered', async t => {
const smpp = await startServer(t);
smpp.on('session', peer => { peer.on('sms', () => undefined); });
const { session } = await connect(t, smpp, { responseTimeout: 60 });
assert.ok(session);
const sent = await session.sendSms({ from: '46701113311', message: 'no answer', to: '46709771337' });
assert.match(sent.err?.message ?? '', /may have accepted/);
assert.equal(sent.unanswered, 1, 'a slow SMSC may still have taken it');
});
test('counts a segment aborted after it went out as unanswered', async t => {
const smpp = await startServer(t);
const arrived = once<Sms>(resolve => { smpp.on('session', peer => peer.on('sms', resolve)); });
const { session } = await connect(t, smpp);
assert.ok(session);
const controller = new AbortController();
const sending = session.sendSms(
{ from: '46701113311', message: 'aborted mid-flight', to: '46709771337' },
{ signal: controller.signal },
);
await arrived;
controller.abort();
const sent = await sending;
assert.match(sent.err?.message ?? '', /may have accepted/);
assert.equal(sent.unanswered, 1, 'the abort is ours; the peer still holds the request');
});
test('reports a segment the link dropped under as unanswered, not as never sent', async t => {
const smpp = await startServer(t);
const arrived = once<Sms>(resolve => { smpp.on('session', peer => peer.on('sms', resolve)); });
@@ -733,6 +815,32 @@ describe('sends across a reconnect', () => {
});
});
describe('LinkGate', () => {
test('refuses a hold whose deadline has already passed', async () => {
let now = 0;
const gate = new LinkGate({ now: () => now, timeout: 100 });
const deadline = gate.deadline();
gate.shut(true);
now = 101;
const held = await gate.wait(deadline, undefined);
assert.match(held.err?.message ?? '', /did not come back in time/);
});
// addEventListener never fires for a signal that already aborted, so it would wait out the timeout.
test('gives up at once on a signal that was already aborted', async () => {
const gate = new LinkGate({ timeout: 100 });
gate.shut(true);
const held = await gate.wait(gate.deadline(), AbortSignal.abort());
assert.match(held.err?.message ?? '', /Aborted while waiting for a link/);
});
});
describe('reassembly bounds', () => {
function segment(reference: number, part: number, total: number): PduObject {
const udh = Buffer.from([0x05, 0x00, 0x03, reference, total, part]);
@@ -1090,8 +1198,8 @@ describe('graceful shutdown', () => {
assert.ok(first.sock);
const rebinding = gate();
const release = gate();
const rebinding = latch();
const release = latch();
const session = new Session({
reconnect: {
connect: open,