Say what a peer did with each receipt segment, and leave no drop unlogged

This commit is contained in:
2026-09-01 17:59:37 +02:00
parent 67c0402def
commit 61985fb914
7 changed files with 113 additions and 36 deletions
+15 -9
View File
@@ -350,13 +350,18 @@ Grouped by what each one constrains.
is one turn late, so a listener that sends its receipt straight after the response is still is one turn late, so a listener that sends its receipt straight after the response is still
holding when the drain looks; `sendDlr()` is the one send that goes out past the drain's refusal, holding when the drain looks; `sendDlr()` is the one send that goes out past the drain's refusal,
and only while the message is still held — past that it is an ordinary send, because the drain it and only while the message is still held — past that it is an ordinary send, because the drain it
would slip past is no longer waiting for it. `shutdownTimeout: 0` does not carry over to this would slip past is no longer waiting for it. `shutdownTimeout: 0` does not carry over to this half:
half: waiting forever is safe for the peer, whose every request is bounded by `responseTimeout` waiting forever is safe for the peer, whose every request is bounded by `responseTimeout` unless the
unless the caller set that to 0 as well, caller set that to 0 as well, and unsafe for the application, which nothing bounds — `close()` is
and unsafe for the application, which nothing bounds — `close()` is what you reach for when the what you reach for when the application is stuck, so it may not block on the application coming
application is stuck, so it may not block on the application coming unstuck. That half falls back unstuck. That half falls back to `responseTimeout`, the same answer the link gate's hold already
to `responseTimeout`, the same answer the link gate's hold already takes and to that option's takes — and to that option's default where it is 0 as well, since neither option is an answer about
default where it is 0 as well, since neither option is an answer about the application. the application. What is held is capped and expiring like every other inbound store, on constants
rather than options, because a bound the application cannot raise is the point: an application that
answers nothing would otherwise grow it for the life of the link, which goal 4 forbids. A message
that falls out of the bound is one the drain stops waiting for, so `close()` can report fewer
unanswered than there were — accepted, because the alternative is holding what nothing will answer,
and both exits are logged.
- **A reconnect keeps the delivery-receipt merges; everything else the link held is dropped.** - **A reconnect keeps the delivery-receipt merges; everything else the link held is dropped.**
`onDeliverSm()` answers each receipt before the group it belongs to is complete, and `teardown()` `onDeliverSm()` answers each receipt before the group it belongs to is complete, and `teardown()`
@@ -400,8 +405,9 @@ Grouped by what each one constrains.
`unbind()` takes through `now()`. The gate is told what happened and never reads back into the `unbind()` takes through `now()`. The gate is told what happened and never reads back into the
session: a collaborator that has to ask does not own its decision, which is how the first cut ended session: a collaborator that has to ask does not own its decision, which is how the first cut ended
up answering the same question two different ways at admit and at release. For the same reason the up answering the same question two different ways at admit and at release. For the same reason the
retry in `pastDrain()` asks `gate.isUp()` rather than `linkDown()`, which also reads the socket — a condition that loops on retry in `pastDrain()` asks `gate.isUp()` rather than `linkDown()`, which also reads the socket — a
something the gate does not gate on spins against a gate that admits it straight back. condition that loops on something the gate does not gate on spins against a gate that admits it
straight back.
`LinkGate.returning` is a copy of `retrying()` taken at teardown, and stays true only because `LinkGate.returning` is a copy of `retrying()` taken at teardown, and stays true only because
nothing stops the reconnect loop without `emitClose()` following it: `drain()` and `end()` are the nothing stops the reconnect loop without `emitClose()` following it: `drain()` and `end()` are the
only callers of `stop()`. A third caller has to shut the gate itself. only callers of `stop()`. A third caller has to shut the gate itself.
+2 -1
View File
@@ -351,7 +351,8 @@ const { err, pduObj } = await session.send({
A send issued while the link is down waits for the reconnect instead of failing, and goes out once A send issued while the link is down waits for the reconnect instead of failing, and goes out once
the new link is bound — up to `responseTimeout`, after which it gives up having sent nothing. A the new link is bound — up to `responseTimeout`, after which it gives up having sent nothing. A
request already on the wire is the other case: the SMSC may have taken it and lost only the response, request already on the wire is the other case: the SMSC may have taken it and lost only the response,
so it fails, and `sendSms()` counts it in `unanswered`, whether the link dropped under it, the peer so it fails, and `sendSms()` and `sms.sendDlr()` count it in `unanswered`, whether the link dropped
under it, the peer
never answered in time, or you aborted it after it went out. Neither applies with `reconnect: false`, never answered in time, or you aborted it after it went out. Neither applies with `reconnect: false`,
where a drop ends the session and every send after it is refused. where a drop ends the session and every send after it is refused.
+16 -6
View File
@@ -21,6 +21,7 @@ export class HeldMessages {
private readonly held: ExpiringGroups<PduObject[]>; private readonly held: ExpiringGroups<PduObject[]>;
private readonly idleWaiters = new IdleWaiters(); private readonly idleWaiters = new IdleWaiters();
private readonly log: SmppLog; private readonly log: SmppLog;
private readonly max: number;
constructor(options: HeldMessagesOptions) { constructor(options: HeldMessagesOptions) {
this.held = new ExpiringGroups({ this.held = new ExpiringGroups({
@@ -29,6 +30,7 @@ export class HeldMessages {
timeout: options.timeout, timeout: options.timeout,
}); });
this.log = options.log; this.log = options.log;
this.max = options.max;
} }
/** An application that answers no message at all may not grow this without end. */ /** An application that answers no message at all may not grow this without end. */
@@ -37,12 +39,10 @@ export class HeldMessages {
if (key === undefined) return; if (key === undefined) return;
if (this.held.full) { if (this.held.get(key)) {
const evicted = this.held.takeOldest(); this.log.warn('heldMessages - replacing a message on a re-used sequence number', { seqNr: key });
} else if (this.held.full) {
if (evicted) { this.dropOldest();
this.log.warn('heldMessages - dropping the message held longest', { seqNr: evicted[0] });
}
} }
this.held.set(key, pduObjs); this.held.set(key, pduObjs);
@@ -76,6 +76,16 @@ export class HeldMessages {
return this.idleWaiters.wait(() => this.held.size, timeout, signal); return this.idleWaiters.wait(() => this.held.size, timeout, signal);
} }
private dropOldest(): void {
const oldest = this.held.takeOldest();
if (!oldest) return;
const [seqNr] = oldest;
this.log.warn('heldMessages - buffer full, dropping the oldest message', { max: this.max, seqNr });
}
private sweep(): void { private sweep(): void {
const expired = this.held.takeExpired(); const expired = this.held.takeExpired();
+1 -1
View File
@@ -18,7 +18,7 @@ export class IdleWaiters {
* Resolves 0 once nothing is left, or with what still is when the timeout or the signal cuts the * Resolves 0 once nothing is left, or with what still is when the timeout or the signal cuts the
* wait short. A timeout of 0 waits forever. * wait short. A timeout of 0 waits forever.
*/ */
wait(remaining: () => number, timeout: number, signal?: AbortSignal): Promise<number> { wait(remaining: () => number, timeout: number, signal: AbortSignal | undefined): Promise<number> {
if (remaining() === 0) return Promise.resolve(0); if (remaining() === 0) return Promise.resolve(0);
if (signal?.aborted === true) return Promise.resolve(remaining()); if (signal?.aborted === true) return Promise.resolve(remaining());
+1 -1
View File
@@ -43,7 +43,7 @@ export class SendWindow {
} }
/** Resolves 0 once nothing is left on the wire, or with what still is. */ /** Resolves 0 once nothing is left on the wire, or with what still is. */
idle(timeout: number, signal?: AbortSignal): Promise<number> { idle(timeout: number, signal: AbortSignal | undefined): Promise<number> {
return this.idleWaiters.wait(() => this.unfinished(), timeout, signal); return this.idleWaiters.wait(() => this.unfinished(), timeout, signal);
} }
} }
+23 -15
View File
@@ -137,6 +137,28 @@ function receiptTlvs(smsId: string, status: MessageState): Record<string, TlvInp
}; };
} }
function collectReceipt(sent: Result<{ pduObj: PduObject }>[]): SendDlrResult {
const pduObjs: PduObject[] = [];
let failure: Error | undefined;
let unanswered = 0;
for (const one of sent) {
if (one.err) {
if (one.err instanceof UnansweredError) unanswered++;
failure ??= one.err;
} else if (one.pduObj.cmdStatus === 'ESME_ROK') {
pduObjs.push(one.pduObj);
} else {
const refusal = one.pduObj.cmdStatus ?? String(one.pduObj.cmdStatusId);
failure ??= new Error(`deliver_sm refused by the peer: ${refusal}`);
}
}
return failure ? { err: failure, pduObjs, unanswered } : { pduObjs, unanswered };
}
async function sendDlr( async function sendDlr(
sms: Sms, sms: Sms,
send: SmsHandlers['send'], send: SmsHandlers['send'],
@@ -166,19 +188,5 @@ async function sendDlr(
...(sms.session.acceptsOptionalParams() ? { tlvs: receiptTlvs(smsId, status) } : {}), ...(sms.session.acceptsOptionalParams() ? { tlvs: receiptTlvs(smsId, status) } : {}),
}); });
})); }));
const pduObjs: PduObject[] = []; return collectReceipt(sent);
let failure: Error | undefined;
let unanswered = 0;
for (const one of sent) {
if (!one.err) {
pduObjs.push(one.pduObj);
} else {
if (one.err instanceof UnansweredError) unanswered++;
failure ??= one.err;
}
}
return failure ? { err: failure, pduObjs, unanswered } : { pduObjs, unanswered };
} }
+55 -3
View File
@@ -13,6 +13,8 @@ import type { Sms } from '../src/sms.ts';
import type { SmppServer } from '../src/server.ts'; import type { SmppServer } from '../src/server.ts';
import type { TestContext } from 'node:test'; import type { TestContext } from 'node:test';
import { HeldMessages } from '../src/held-messages.ts'; import { HeldMessages } from '../src/held-messages.ts';
import { UnansweredError } from '../src/unanswered-error.ts';
import { createSms } from '../src/sms.ts';
import { LinkGate } from '../src/link-gate.ts'; import { LinkGate } from '../src/link-gate.ts';
import { Reassembler, decodeSegments } from '../src/reassembly.ts'; import { Reassembler, decodeSegments } from '../src/reassembly.ts';
import { Session } from '../src/session.ts'; import { Session } from '../src/session.ts';
@@ -860,8 +862,8 @@ describe('LinkGate', () => {
// Goal 4: an application that answers nothing must not grow this for the life of the link. // Goal 4: an application that answers nothing must not grow this for the life of the link.
describe('held message bounds', () => { describe('held message bounds', () => {
function message(seqNr: number): PduObject[] { function heldPdu(seqNr: number): PduObject {
return [{ return {
cmdId: 0x00000004, cmdId: 0x00000004,
cmdLength: 0, cmdLength: 0,
cmdName: 'submit_sm', cmdName: 'submit_sm',
@@ -870,7 +872,11 @@ describe('held message bounds', () => {
params: { destination_addr: '46709771337', short_message: 'held', source_addr: '46701113311' }, params: { destination_addr: '46709771337', short_message: 'held', source_addr: '46701113311' },
seqNr, seqNr,
tlvs: {}, tlvs: {},
}]; };
}
function message(seqNr: number): PduObject[] {
return [heldPdu(seqNr)];
} }
test('drops the message held longest rather than holding every one', async () => { test('drops the message held longest rather than holding every one', async () => {
@@ -893,6 +899,37 @@ describe('held message bounds', () => {
assert.equal(await held.idle(1, undefined), 1); assert.equal(await held.idle(1, undefined), 1);
assert.equal(await held.idle(1000, undefined), 0); assert.equal(await held.idle(1000, undefined), 0);
}); });
// A receipt cannot be resent wholesale without duplicating the segments that landed, so sendDlr()
// names what the peer took and what it may have, the way sendSms() does.
test('a partial receipt names the segments the peer took and the ones it may have', async t => {
const session = new Session({ sock: new net.Socket() });
closeAfter(t, session);
let call = 0;
const sms = createSms({
from: '46701113311',
message: 'three segments',
pduObjs: [heldPdu(1), heldPdu(2), heldPdu(3)],
session,
to: '46709771337',
}, {
onAnswered: () => undefined,
send: () => {
call++;
return Promise.resolve(call === 2
? { err: new UnansweredError(new Error('nothing came back')) }
: { pduObj: heldPdu(call) });
},
});
const report = await sms.sendDlr('DELIVERED');
assert.ok(report.err instanceof Error);
assert.equal(report.pduObjs.length, 2);
assert.equal(report.unanswered, 1);
});
}); });
describe('reassembly bounds', () => { describe('reassembly bounds', () => {
@@ -1112,6 +1149,21 @@ describe('graceful shutdown', () => {
assert.deepEqual(await closed, {}); assert.deepEqual(await closed, {});
}); });
// The drain refuses sends; a response was never a send, and saying so is the more useful answer.
test('names a response put through send() as the misuse it is, even mid-shutdown', async t => {
const { sent, session, sms } = await submitInFlight(t);
const closing = session.close();
const refused = await session.send({ cmdName: 'submit_sm_resp' });
assert.ok(refused.err instanceof Error);
assert.match(refused.err.message, /Use sendReturn\(\)/);
await sms.sendResp({ smsId: 'answered-after-the-misuse' });
assert.deepEqual((await sent).smsIds, ['answered-after-the-misuse']);
assert.deepEqual(await closing, {});
});
test('unbind() waits out a submit already on the wire before it unbinds', async t => { test('unbind() waits out a submit already on the wire before it unbinds', async t => {
const { sent, session, sms } = await submitInFlight(t); const { sent, session, sms } = await submitInFlight(t);
const unbound = session.unbind(); const unbound = session.unbind();