Merge pull request #76 from larvit/hold-until-answered

Release a message's hold when the response goes out, or when its listener failed
This commit is contained in:
2026-09-04 10:43:44 +02:00
committed by GitHub
7 changed files with 153 additions and 37 deletions
+14 -7
View File
@@ -34,8 +34,10 @@ one wins. They do not override the hard rules below.
promise this library can verify. The low-level surface is a passthrough: policy binds what the promise this library can verify. The low-level surface is a passthrough: policy binds what the
library composes, never what the caller wrote. library composes, never what the caller wrote.
7. **Nothing that needs state wider than one session.** No throughput throttling, no persistence 7. **Nothing that needs state wider than one session.** No throughput throttling, no persistence
across a restart, no coordination between processes. This is the scope floor, and it is why an across a restart, no coordination between processes — and no seam handing the application state to
otherwise reasonable feature is declined without a fresh argument each time. persist for one of those either, which commits to the same scope through the back door and
publishes an internal shape to do it. This is the scope floor, and it is why an otherwise
reasonable feature is declined without a fresh argument each time.
8. **It builds, tests and runs the same everywhere.** Container-only toolchain, no runtime 8. **It builds, tests and runs the same everywhere.** Container-only toolchain, no runtime
dependencies, the Node 18 floor verified in CI rather than asserted, every README example executed dependencies, the Node 18 floor verified in CI rather than asserted, every README example executed
by the suite. by the suite.
@@ -351,11 +353,16 @@ Grouped by what each one constrains.
drain waits for. Counting every inbound request until `sendReturn()` answered it was rejected — drain waits for. Counting every inbound request until `sendReturn()` answered it was rejected —
an `onRequest` that deliberately answers nothing would then cost a full `shutdownTimeout` on every an `onRequest` that deliberately answers nothing would then cost a full `shutdownTimeout` on every
close — and a message no listener took is released at once, since nothing is going to answer it. close — and a message no listener took is released at once, since nothing is going to answer it.
`teardown()` drops what is still held for the same reason it drops inbound segments. The release A listener that failed before answering gives it up the same way, but only once every listener has:
is one turn late, so a listener that sends its receipt straight after the response is still a throw stops `emit()` where it stands, while a rejection leaves the others running, so the release
holding when the drain looks; `sendDlr()` is the one send that goes out past the drain's refusal, waits for the last of them rather than answering on their behalf. What ends the wait is the response
and only while the message is still held — past that it is an ordinary send, because the drain it reaching the wire, not the call — a `sendResp()` the library refused, or one the socket would not
would slip past is no longer waiting for it. `shutdownTimeout: 0` does not carry over to this half: carry, leaves the message held, so `close()` still reports the one the peer is owed. `teardown()`
drops what is still held for the same reason it drops inbound segments. The release 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, 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 half:
waiting forever is safe for the peer, whose every request is bounded by `responseTimeout` unless the waiting forever is safe for the peer, whose every request is bounded by `responseTimeout` unless the
caller set that to 0 as well, and unsafe for the application, which nothing bounds — `close()` is caller set that to 0 as well, and unsafe for the application, which nothing bounds — `close()` is
what you reach for when the application is stuck, so it may not block on the application coming what you reach for when the application is stuck, so it may not block on the application coming
+7 -6
View File
@@ -332,12 +332,13 @@ TypeScript users can import `SmppLog` to have the compiler check one.
`sendSms()`, `send()`, `sendReturn()`, `unbind()` and `close()`. Both `close()` and `unbind()` `sendSms()`, `send()`, `sendReturn()`, `unbind()` and `close()`. Both `close()` and `unbind()`
refuse further sends, wait out the requests this end already sent for up to `shutdownTimeout`, and refuse further sends, wait out the requests this end already sent for up to `shutdownTimeout`, and
then tear down whatever is left, resolving to an `err` that says what was lost. They also wait for then tear down whatever is left, resolving to an `err` that says what was lost. They also wait for
every `sms` the application has not called `sendResp()` on, so a peer whose `submit_sm` is still every `sms` still in the application's hands, so a peer whose `submit_sm` is being handled is
being handled is answered rather than left to re-send it — answering its PDUs through `sendReturn()` answered rather than left to re-send it. That wait ends when `sendResp()` puts the response on the
instead leaves that wait running until it gives up. `sendDlr()` is the one send the refusal lets wire, or when every listener that took the message has failed; answering its PDUs through
past, and it catches the wait when issued straight after `sendResp()`; await anything in between and `sendReturn()` instead leaves the wait running until it gives up. `sendDlr()` is the one send the
it races the shutdown like any other send. `close({ signal })` takes an refusal lets past, and it catches the wait when issued straight after `sendResp()`; await anything in
`AbortSignal` that cuts the wait short; `unbind()` takes none, and waits a further between and it races the shutdown like any other send. `close({ signal })` takes an `AbortSignal`
that cuts the wait short; `unbind()` takes none, and waits a further
`responseTimeout` for its own response. `send()` reaches any of the 33 SMPP commands the codec `responseTimeout` for its own response. `send()` reaches any of the 33 SMPP commands the codec
knows, not just the four the session handles natively: knows, not just the four the session handles natively:
+20 -2
View File
@@ -31,6 +31,8 @@ export type IncomingRequestsOptions = {
/** Everything the peer asks of a session: messages, receipts, links and the answers to them. */ /** Everything the peer asks of a session: messages, receipts, links and the answers to them. */
export class IncomingRequests { export class IncomingRequests {
private readonly dlrMerger: DlrMerger; private readonly dlrMerger: DlrMerger;
/** The rejection handler is handed the Sms back as an `unknown`, so its hold is found by identity. */
private readonly emitted = new WeakMap<object, () => void>();
private readonly held: HeldMessages; private readonly held: HeldMessages;
private readonly log: SmppLog; private readonly log: SmppLog;
private readonly onRequest: OnRequest | undefined; private readonly onRequest: OnRequest | undefined;
@@ -111,6 +113,13 @@ export class IncomingRequests {
this.reassembler.clear(); this.reassembler.clear();
} }
/** One `sms` listener gave up on a message; the last one to do so is what releases the hold. */
listenerRejected(sms: unknown): void {
if (typeof sms !== 'object' || sms === null) return;
this.emitted.get(sms)?.();
}
/** Waits out the messages the application still holds, and says how many it never answered. */ /** Waits out the messages the application still holds, and says how many it never answered. */
async drain(timeout: number, signal: AbortSignal | undefined): Promise<VoidResult> { async drain(timeout: number, signal: AbortSignal | undefined): Promise<VoidResult> {
const unanswered = await this.held.idle(timeout, signal); const unanswered = await this.held.idle(timeout, signal);
@@ -175,6 +184,8 @@ export class IncomingRequests {
if (!first) return; if (!first) return;
const generation = this.linkGeneration; const generation = this.linkGeneration;
// A turn later, so a listener sending its receipt straight after the response still holds.
const release = (): void => { setImmediate(() => { this.held.release(pduObjs); }); };
const sms = createSms({ const sms = createSms({
from: paramText(first.params.source_addr), from: paramText(first.params.source_addr),
@@ -184,13 +195,20 @@ export class IncomingRequests {
to: paramText(first.params.destination_addr), to: paramText(first.params.destination_addr),
}, { }, {
lostLink: () => this.linkGeneration !== generation, lostLink: () => this.linkGeneration !== generation,
// A turn later, so a listener sending its receipt straight after the response still holds. onAnswered: release,
onAnswered: () => { setImmediate(() => { this.held.release(pduObjs); }); },
// Past the refusal only while a drain is still waiting for this message; an ordinary send after. // Past the refusal only while a drain is still waiting for this message; an ordinary send after.
send: input => (this.held.has(pduObjs) ? this.sendPastDrain(input) : this.session.send(input)), send: input => (this.held.has(pduObjs) ? this.sendPastDrain(input) : this.session.send(input)),
}); });
// A rejection leaves the other listeners running, so only the last one to fail gives the message up.
let working = this.session.listenerCount('sms');
this.held.hold(pduObjs); this.held.hold(pduObjs);
this.emitted.set(sms, () => {
working--;
if (working <= 0) release();
});
// A message nobody took is not work a shutdown can wait for. // A message nobody took is not work a shutdown can wait for.
if (!this.session.emit('sms', sms)) this.held.release(pduObjs); if (!this.session.emit('sms', sms)) this.held.release(pduObjs);
+3 -1
View File
@@ -93,11 +93,13 @@ export class Session extends EventEmitter<SessionEvents> {
reason: unknown, reason: unknown,
...args: [event: keyof SessionEvents, ...rest: unknown[]] ...args: [event: keyof SessionEvents, ...rest: unknown[]]
): void { ): void {
const [event] = args; const [event, ...rest] = args;
const error = errorFrom(reason); const error = errorFrom(reason);
this.log.error('session - a listener rejected', { event, message: error.message }); this.log.error('session - a listener rejected', { event, message: error.message });
if (event === 'sms') this.incoming.listenerRejected(rest[0]);
if (event !== 'sessionError') this.emit('sessionError', error); if (event !== 'sessionError') this.emit('sessionError', error);
} }
+8 -5
View File
@@ -77,8 +77,7 @@ export function createSms(input: SmsInput, handlers: SmsHandlers): Sms {
message: input.message, message: input.message,
pduObjs: input.pduObjs, pduObjs: input.pduObjs,
sendDlr: status => sendDlr(sms, handlers.send, status), sendDlr: status => sendDlr(sms, handlers.send, status),
sendResp: options => sendResp(sms, answered, options ?? {}, handlers.lostLink) sendResp: options => sendResp(sms, answered, options ?? {}, handlers),
.finally(handlers.onAnswered),
session: input.session, session: input.session,
get smsId(): string { get smsId(): string {
return answered.smsId; return answered.smsId;
@@ -94,7 +93,7 @@ async function sendResp(
sms: Sms, sms: Sms,
answered: { smsId: string }, answered: { smsId: string },
options: SendRespOptions, options: SendRespOptions,
lostLink: () => boolean, handlers: Pick<SmsHandlers, 'lostLink' | 'onAnswered'>,
): Promise<VoidResult> { ): Promise<VoidResult> {
const total = sms.pduObjs.length; const total = sms.pduObjs.length;
@@ -109,7 +108,7 @@ async function sendResp(
if (options.smsId !== undefined) answered.smsId = options.smsId; if (options.smsId !== undefined) answered.smsId = options.smsId;
// A response carries the sequence number it was asked on, which the next link knows nothing about. // A response carries the sequence number it was asked on, which the next link knows nothing about.
if (lostLink()) { if (handlers.lostLink()) {
return { err: new Error('The link this message arrived on is gone, so nothing would correlate the response') }; return { err: new Error('The link this message arrived on is gone, so nothing would correlate the response') };
} }
@@ -119,7 +118,11 @@ async function sendResp(
{ message_id: segmentId(answered.smsId, index, total) }, { message_id: segmentId(answered.smsId, index, total) },
))); )));
return results.find(result => result.err) ?? {}; const failure = results.find(result => result.err);
if (!failure) handlers.onAnswered();
return failure ?? {};
} }
/** The receipt as text, which is all of it a peer below SMPP 3.4 is allowed to be sent. */ /** The receipt as text, which is all of it a peer below SMPP 3.4 is allowed to be sent. */
+93
View File
@@ -1012,6 +1012,33 @@ describe('held message bounds', () => {
}); });
}); });
describe('sendResp()', () => {
// A response the wire never carried leaves the peer owed one, so nothing may count it answered.
test('does not count a response that never reached the wire as an answer', async t => {
const sock = new net.Socket();
const session = new Session({ sock });
closeAfter(t, session);
sock.destroy();
let answered = 0;
const sms = createSms({
from: '46701113311',
message: 'never answered',
pduObjs: [submitPdu(1)],
session,
to: '46709771337',
}, {
lostLink: () => false,
onAnswered: () => { answered++; },
send: () => Promise.resolve({ err: new Error('never sent') }),
});
assert.match((await sms.sendResp()).err?.message ?? '', /Socket is closed/);
assert.equal(answered, 0);
});
});
describe('sendDlr()', () => { describe('sendDlr()', () => {
// A receipt cannot be resent wholesale without duplicating the segments that landed. // A receipt cannot be resent wholesale without duplicating the segments that landed.
test('names the segments the peer took, refused, and may have taken', async t => { test('names the segments the peer took, refused, and may have taken', async t => {
@@ -1401,6 +1428,72 @@ describe('graceful shutdown', () => {
assert.ok((await sent).err instanceof Error); assert.ok((await sent).err instanceof Error);
}); });
// emit() releases the hold of a listener that throws; one that rejects may cost no more than that.
test('a listener that rejected before answering does not hold the shutdown up', async t => {
const smpp = await startServer(t, { shutdownTimeout: 30_000 });
const failed = once<Error>(resolve => {
smpp.on('session', bound => {
bound.on('sessionError', resolve);
bound.on('sms', () => Promise.reject(new Error('the listener gave up')));
});
});
const { session } = await connect(t, smpp);
assert.ok(session);
const sent = session.sendSms({
from: '46701113311',
message: 'the listener rejects',
to: '46709771337',
});
assert.equal((await failed).message, 'the listener gave up');
const started = Date.now();
assert.deepEqual(await peerOf(smpp).close(), {});
assert.ok(Date.now() - started < 1000);
assert.ok((await sent).err instanceof Error);
});
test('waits for the listener still working when another one rejected', async t => {
const smpp = await startServer(t, { shutdownTimeout: 30_000 });
const failed = once<Error>(resolve => {
smpp.on('session', bound => {
bound.on('sessionError', resolve);
bound.on('sms', async sms => {
await delay(100);
await sms.sendResp({ smsId: 'answered-after-the-other-gave-up' });
});
bound.on('sms', () => Promise.reject(new Error('the audit listener gave up')));
});
});
const { session } = await connect(t, smpp);
assert.ok(session);
const sent = session.sendSms({
from: '46701113311',
message: 'two listeners, one gives up',
to: '46709771337',
});
assert.equal((await failed).message, 'the audit listener gave up');
assert.deepEqual(await peerOf(smpp).close(), {});
assert.deepEqual((await sent).smsIds, ['answered-after-the-other-gave-up']);
});
// Nothing reached the peer, so a drain counting this answered would report an outcome that never was.
test('leaves a message the library refused to answer unanswered', async t => {
const { sms, smpp } = await submitInFlight(t, {}, { shutdownTimeout: 50 });
const refused = await sms.sendResp({ smsId: '' });
const closed = await peerOf(smpp).close();
assert.match(refused.err?.message ?? '', /smsId must not be empty/);
assert.ok(closed.err instanceof Error);
assert.match(closed.err.message, /1 message\(s\) unanswered/);
});
test('gives up on a request that outlasts shutdownTimeout', async t => { test('gives up on a request that outlasts shutdownTimeout', async t => {
const { sent, session } = await submitInFlight(t, { shutdownTimeout: 50 }); const { sent, session } = await submitInFlight(t, { shutdownTimeout: 50 });
const closed = await session.close(); const closed = await session.close();
+8 -16
View File
@@ -61,6 +61,7 @@ Rules the API follows:
| Held messages capped and expiring, so an application that answers nothing cannot grow them | `test/session-extras.test.ts` | | Held messages capped and expiring, so an application that answers nothing cannot grow them | `test/session-extras.test.ts` |
| A send with no link held for the next one, and one the link dropped under counted as `unanswered` | `test/session-extras.test.ts` | | A send with no link held for the next one, and one the link dropped under counted as `unanswered` | `test/session-extras.test.ts` |
| A message whose link dropped refused an answer, with its receipt still allowed out | `test/session-extras.test.ts` | | A message whose link dropped refused an answer, with its receipt still allowed out | `test/session-extras.test.ts` |
| The hold released exactly when the peer was answered: a refused `sendResp()` keeps it, a listener that rejected drops it | `test/session-extras.test.ts` |
| Every runnable README example | `test/readme.test.ts` | | Every runnable README example | `test/readme.test.ts` |
| Receipt-versus-message classification by `esm_class` | `test/dlr.test.ts`, `test/session.test.ts` | | Receipt-versus-message classification by `esm_class` | `test/dlr.test.ts`, `test/session.test.ts` |
| A listener that throws, or rejects, reaching `sessionError`/`serverError` rather than the process | `test/session.test.ts`, `test/error-from.test.ts` | | A listener that throws, or rejects, reaching `sessionError`/`serverError` rather than the process | `test/session.test.ts`, `test/error-from.test.ts` |
@@ -117,10 +118,6 @@ session message is a change to every call site.
## Worth doing, not blocking ## Worth doing, not blocking
- [ ] **Merge state does not survive a process restart.** A drop no longer discards it, but a restart
loses every incomplete group, and a peer has no reason to resend a receipt it already had
answered. Surviving one means exposing the merge state for the application to persist and hand
back, which is a public-surface decision.
- [ ] **Group the session's collaborators under `src/session/`.** `session.ts` imports - [ ] **Group the session's collaborators under `src/session/`.** `session.ts` imports
`dlr-merger`, `incoming-requests`, `link-timers`, `outgoing-requests`, `pdu-transport`, `dlr-merger`, `incoming-requests`, `link-timers`, `outgoing-requests`, `pdu-transport`,
`reconnect-loop` and `send-sms`, and nothing else does, so the directory would make that `reconnect-loop` and `send-sms`, and nothing else does, so the directory would make that
@@ -134,18 +131,6 @@ session message is a change to every call site.
and applies it to the other is wrong. A budget type both take would close it. Raised by review, and applies it to the other is wrong. A budget type both take would close it. Raised by review,
2026-09-01. 2026-09-01.
- [ ] **An `sms` listener that rejects before answering costs a whole `shutdownTimeout`.**
One that *throws* is fine: `emit()` catches it, returns false, and `emitSms()` releases the
hold. A rejecting `async` one reaches `sessionError` through `captureRejections`, which hands
the handler an `unknown[]` the `Sms` cannot be read out of without a cast, so nothing releases
until the message expires. Same cost as the `onRequest`-answers-nothing case that was declined,
but reached by a bug rather than a policy. Raised by review, 2026-09-01.
- [ ] **A refused `sendResp()` releases the hold anyway.** Every early return runs
`.finally(onAnswered)`, so `sendResp({ smsId: '' })` refuses and stops the drain waiting for a
message the peer was never answered, which `close()` then reports as answered. Raised by
review, 2026-09-01.
- [ ] **Does an intermediate delivery notification deserve to be a `dlr`?** `esm_class` message type - [ ] **Does an intermediate delivery notification deserve to be a `dlr`?** `esm_class` message type
`INTERMEDIATE_DELIVERY` (0x20) is classified as a message today, so a peer that reports `INTERMEDIATE_DELIVERY` (0x20) is classified as a message today, so a peer that reports
non-final states with it hands the application a raw `id:… stat:ENROUTE` text as an inbound non-final states with it hands the application a raw `id:… stat:ENROUTE` text as an inbound
@@ -179,6 +164,13 @@ session message is a change to every call site.
## Declined ## Declined
- **Merge state surviving a process restart.** Declined by AGENTS.md goal 7, maintainer's call,
2026-09-02. A restart loses every incomplete receipt group and a peer has no reason to resend one it
already had answered, so the loss is real — but surviving it means handing the application the merge
state to persist, which the scope floor covers as squarely as holding the state here would, and
which publishes the shape of `DlrMerger`'s groups against goal 6. Nothing is foreclosed: the seam
can still be added after 1.0.0 as a minor.
- **Throughput throttling — a TPS cap, and backing off on `ESME_RTHROTTLED`.** Declined by AGENTS.md - **Throughput throttling — a TPS cap, and backing off on `ESME_RTHROTTLED`.** Declined by AGENTS.md
goal 7: an operator's rate limit is scoped to the account, while the widest thing this library owns goal 7: an operator's rate limit is scoped to the account, while the widest thing this library owns
is a session, so a bucket here cannot see a second process binding the same account and is wrong in is a session, so a bucket here cannot see a second process binding the same account and is wrong in