Refuse a single unreadable PDU instead of the whole link (#79)

* Regression tests: a PDU the codec cannot read costs that PDU, not the link

* Refuse a single unreadable PDU instead of the whole link

* smscsim interop asserts the first attempt gets every DLR

* Record the smscsim sequence number defect as fixed

* One framing rule and one response-command lookup, per the architecture review

* Apply the stability review's nits: honest sessionError docs and one link-survives assertion
This commit is contained in:
2026-09-05 21:00:52 +02:00
committed by GitHub
parent fec4fda082
commit ea42bc6d20
11 changed files with 511 additions and 141 deletions
+5
View File
@@ -106,6 +106,11 @@ are silently lost and bounce the link. Against a spec-conforming peer (small inc
numbers) it never fires, so it is plausibly why the suite's own dummy peers never caught it - which
is the whole reason this experiment exists.
**Fixed** in PR #79: only a framing error tears the link down now, any 32-bit `sequence_number` is
read and echoed, and `smscsim.test.ts`'s retry crutch is gone. A rerun of `./interop-tests/run.py
smscsim` shows 54 frames, `deliver_sm: 6` answered by `deliver_sm_resp: 6`, `bind_transceiver: 5`
(no reconnects), `malformed: 0`, `expert errors: 0`, 8/8 tests passing on their first attempt.
## Peer quirks
- No PDU validation (documented): a bad `interface_version` or malformed PDU is never rejected.
+30 -46
View File
@@ -12,14 +12,8 @@ const PEER_WEB_PORT = Number(process.env.PEER_WEB_PORT ?? '12775');
const FAILING_PEER_HOST = process.env.FAILING_PEER_HOST ?? 'smscsim-failing';
const FAILING_PEER_PORT = Number(process.env.FAILING_PEER_PORT ?? '2775');
// smscsim signs every deliver_sm it sends unprompted (a DLR, or an injected MO) with a
// `rand.Int()`-derived sequence_number, unconstrained to the SMPP 3.4 4.7.1 ceiling
// (0x7FFFFFFF) - about half land above it, and pdu.ts refuses the PDU outright, so the DLR or
// MO is silently lost (see findings/01-smscsim.md, "an out-of-range deliver_sm sequence
// number"). Retrying with a fresh send works around that peer+library interaction without
// hiding it: a scenario only fails here if it keeps missing well past what chance alone explains.
const DLR_RETRY_BUDGET_MS = 3000;
const DLR_MAX_ATTEMPTS = 20;
// smscsim keys its refusal on each submit_sm's own sequence number parity, so two sends minimum.
const PARITY_MAX_ATTEMPTS = 6;
function delay(ms: number): Promise<void> {
return new Promise(resolve => { setTimeout(resolve, ms); });
@@ -38,22 +32,19 @@ async function waitFor<T>(get: () => T | undefined, budget = 5000): Promise<T |
return value;
}
/** Resends the message until one attempt's segments are every one matched by a `dlr` event. */
async function sendUntilAllDlrsArrive(session: Session, dlrs: Dlr[], message: string): Promise<string[]> {
for (let attempt = 0; attempt < DLR_MAX_ATTEMPTS; attempt++) {
const sent = await session.sendSms({ dlr: true, from: '46701113311', message, to: '46709771337' });
/** Sends once and waits for every segment of it to be matched by a `dlr` event. */
async function sendAndAwaitDlrs(session: Session, dlrs: Dlr[], message: string): Promise<string[]> {
const sent = await session.sendSms({ dlr: true, from: '46701113311', message, to: '46709771337' });
assert.equal(sent.err, undefined);
assert.equal(sent.err, undefined);
const complete = await waitFor(
() => (sent.smsIds.every(id => dlrs.some(dlr => dlr.smsId === id)) ? true : undefined),
DLR_RETRY_BUDGET_MS,
);
const complete = await waitFor(() => (
sent.smsIds.every(id => dlrs.some(dlr => dlr.smsId === id)) ? true : undefined
));
if (complete) return sent.smsIds;
}
assert.ok(complete, 'every segment of the first send should get a DLR');
throw new Error(`no attempt got a DLR for every segment within ${String(DLR_MAX_ATTEMPTS)} tries`);
return sent.smsIds;
}
describe('smscsim - C1 bind, keepalive, unbind', () => {
@@ -113,7 +104,7 @@ describe('smscsim - a single SMS', () => {
session.on('dlr', dlr => { dlrs.push(dlr); });
const smsIds = await sendUntilAllDlrsArrive(session, dlrs, 'hello world');
const smsIds = await sendAndAwaitDlrs(session, dlrs, 'hello world');
assert.equal(smsIds.length, 1);
@@ -142,7 +133,7 @@ describe('smscsim - multipart segments', () => {
// 200 plain GSM chars: over the 160-char single-segment budget, under the 306-char
// 2-segment one (153 septets each).
const smsIds = await sendUntilAllDlrsArrive(session, dlrs, 'a'.repeat(200));
const smsIds = await sendAndAwaitDlrs(session, dlrs, 'a'.repeat(200));
assert.equal(smsIds.length, 2);
});
@@ -160,7 +151,7 @@ describe('smscsim - multipart segments', () => {
// 一 (2 bytes) + an emoji (a surrogate pair, 4 bytes) + 70 padding chars (2 bytes each):
// 146 bytes, over the 140-byte single-segment budget, under the 268-byte 2-segment one.
const smsIds = await sendUntilAllDlrsArrive(session, dlrs, `一😀${'x'.repeat(70)}`);
const smsIds = await sendAndAwaitDlrs(session, dlrs, `一😀${'x'.repeat(70)}`);
assert.equal(smsIds.length, 2);
});
@@ -183,30 +174,23 @@ describe('smscsim - MO injection through the web UI', () => {
session.on('sms', sms => { incoming.push(sms); });
let sms: Sms | undefined;
const response = await fetch(`http://${PEER_HOST}:${String(PEER_WEB_PORT)}/`, {
body: new URLSearchParams({
message: 'hello from the web UI',
recipient: '46709771337',
sender: '46701113311',
system_id: 'mo-inject',
}),
method: 'POST',
redirect: 'manual',
});
for (let attempt = 0; attempt < DLR_MAX_ATTEMPTS && !sms; attempt++) {
const before = incoming.length;
assert.equal(response.status, 303);
assert.match(response.headers.get('location') ?? '', /message=/);
const response = await fetch(`http://${PEER_HOST}:${String(PEER_WEB_PORT)}/`, {
body: new URLSearchParams({
message: 'hello from the web UI',
recipient: '46709771337',
sender: '46701113311',
system_id: 'mo-inject',
}),
method: 'POST',
redirect: 'manual',
});
const sms = await waitFor(() => incoming[0]);
assert.equal(response.status, 303);
assert.match(response.headers.get('location') ?? '', /message=/);
await waitFor(() => incoming[before], DLR_RETRY_BUDGET_MS);
sms = incoming[before];
}
assert.ok(sms, 'no MO message arrived across the attempts');
assert.ok(sms, 'the injected MO should arrive on the first attempt');
assert.equal(sms.from, '46701113311');
assert.equal(sms.to, '46709771337');
assert.equal(sms.message, 'hello from the web UI');
@@ -230,7 +214,7 @@ describe('smscsim-failing - C12 refusals', () => {
let refusedSeen = false;
let acceptedConfirmed = false;
for (let attempt = 0; attempt < DLR_MAX_ATTEMPTS && !(refusedSeen && acceptedConfirmed); attempt++) {
for (let attempt = 0; attempt < PARITY_MAX_ATTEMPTS && !(refusedSeen && acceptedConfirmed); attempt++) {
const before = dlrs.length;
// Sequential: smscsim keys its refusal on each submit_sm's own sequence number parity.
@@ -253,7 +237,7 @@ describe('smscsim-failing - C12 refusals', () => {
assert.ok(smsId);
const matched = await waitFor(() => dlrs.slice(before).find(dlr => dlr.smsId === smsId), DLR_RETRY_BUDGET_MS);
const matched = await waitFor(() => dlrs.slice(before).find(dlr => dlr.smsId === smsId));
if (matched) {
assert.equal(matched.statusMsg, 'UNDELIVERABLE');