Bound the messages held for a shutdown, and report a partial receipt

This commit is contained in:
2026-09-01 17:42:22 +02:00
parent 1e7e113bd7
commit 67c0402def
11 changed files with 201 additions and 41 deletions
+10 -9
View File
@@ -72,7 +72,7 @@ src/
dlr-merger.ts DlrMerger: per-segment receipts counted into one MessageDlr dlr-merger.ts DlrMerger: per-segment receipts counted into one MessageDlr
error-from.ts errorFrom(): whatever was thrown or rejected, as an Error error-from.ts errorFrom(): whatever was thrown or rejected, as an Error
expiring-groups.ts ExpiringGroups: the capped, expiring store both of those share expiring-groups.ts ExpiringGroups: the capped, expiring store both of those share
held-messages.ts HeldMessages: the messages handed to the application and not yet answered held-messages.ts HeldMessages: capped, expiring messages the application has not answered
idle-waiters.ts IdleWaiters: waiting for a count to fall to zero, and what is left of a budget idle-waiters.ts IdleWaiters: waiting for a count to fall to zero, and what is left of a budget
incoming-requests.ts Every request the peer sends: messages, receipts, links, unknown commands incoming-requests.ts Every request the peer sends: messages, receipts, links, unknown commands
link-gate.ts LinkGate: where a request with no link to go out on waits for the next one link-gate.ts LinkGate: where a request with no link to go out on waits for the next one
@@ -351,7 +351,8 @@ Grouped by what each one constrains.
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: waiting forever is safe for the peer, whose every request is bounded by `responseTimeout`, half: 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 what you reach for when the 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 unstuck. That half falls back application is stuck, so it may not block on the application coming unstuck. That half falls back
to `responseTimeout`, the same answer the link gate's hold already takes — and to that option's to `responseTimeout`, the same answer the link gate's hold already takes — and to that option's
@@ -393,13 +394,13 @@ Grouped by what each one constrains.
Maintainer's call, 2026-09-01: `attach()` clears `closed` the moment a socket is handed over, one Maintainer's call, 2026-09-01: `attach()` clears `closed` the moment a socket is handed over, one
round trip before the bind is answered, so gating on `closed` let a send arriving in that window go round trip before the bind is answered, so gating on `closed` let a send arriving in that window go
out unbound and come back `ESME_RINVBNDSTS`. `LinkGate` owns the answer instead — `shut(returning)` out unbound and come back `ESME_RINVBNDSTS`. `LinkGate` owns the answer instead — `shut(returning)`
on every teardown, `open()` only once `comeBackUp()` has a bound link — and `Session.linkDown()` on every teardown, `open()` only once `comeBackUp()` has a bound link — and
reads it rather than `closed`. The bind itself cannot wait for what it creates, so `send()` lets the `OutgoingRequests.linkDown()` reads it rather than `closed`. The bind itself cannot wait for what it
three bind commands past the gate and the window, the same door `unbind()` takes through creates, so `pastDrain()` lets the three bind commands past the gate and the window, the same door
`attempt()`. The gate is told what happened and never reads back into the session: a collaborator `unbind()` takes through `now()`. The gate is told what happened and never reads back into the
that has to ask does not own its decision, which is how the first cut ended up answering the same session: a collaborator that has to ask does not own its decision, which is how the first cut ended
question two different ways at admit and at release. For the same reason the retry in `send()` asks up answering the same question two different ways at admit and at release. For the same reason the
`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 condition that loops on
something the gate does not gate on spins against a gate that admits it straight back. 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
+1 -1
View File
@@ -101,7 +101,7 @@ Every one is optional.
| `enquireLinkInterval` | `20000` | How often to send `enquire_link` on a quiet link. | | `enquireLinkInterval` | `20000` | How often to send `enquire_link` on a quiet link. |
| `idleTimeout` | `2 × enquireLinkInterval` | Give up on a link the peer has stopped answering, and re-bind unless `reconnect` is `false`. | | `idleTimeout` | `2 × enquireLinkInterval` | Give up on a link the peer has stopped answering, and re-bind unless `reconnect` is `false`. |
| `responseTimeout` | `30000` | How long to wait for a response before giving up on it, and how long a send with no link waits for the next one; `0` waits forever. | | `responseTimeout` | `30000` | How long to wait for a response before giving up on it, and how long a send with no link waits for the next one; `0` waits forever. |
| `shutdownTimeout` | `5000` | How long `close()` and `unbind()` wait for the requests this end already sent and the messages the application has not answered. `0` waits forever for the requests, which the peer answers or times out; the messages fall back to `responseTimeout`, or to its default where that is 0 too, since nothing but the application ends that wait. | | `shutdownTimeout` | `5000` | How long `close()` and `unbind()` wait for the requests this end already sent and the messages the application has not answered. `0` waits forever for the requests, which end when the peer answers or `responseTimeout` expires — so setting both to `0` never ends. The messages fall back to `responseTimeout`, or to its default where that is `0` too, since nothing but the application ends that wait. |
| `maxOutstanding` | `10` | Requests allowed on the wire at once; further sends queue. | | `maxOutstanding` | `10` | Requests allowed on the wire at once; further sends queue. |
| `smsIdFormat` | — | The notation the SMSC writes message ids in, per place it writes them: `{ receipt: 'decimal', submitResp: 'hex' }`. Only needed where the two disagree. | | `smsIdFormat` | — | The notation the SMSC writes message ids in, per place it writes them: `{ receipt: 'decimal', submitResp: 'hex' }`. Only needed where the two disagree. |
| `reconnect` | on | Re-binds after a drop, an idle timeout, or a stream the library cannot read, backing off from `minDelay` 1 s to `maxDelay` 30 s and starting over at `minDelay` once a link has lasted `maxDelay`. `{ minDelay, maxDelay }` retunes it; `false` turns it off, so a drop ends the session. | | `reconnect` | on | Re-binds after a drop, an idle timeout, or a stream the library cannot read, backing off from `minDelay` 1 s to `maxDelay` 30 s and starting over at `minDelay` once a link has lasted `maxDelay`. `{ minDelay, maxDelay }` retunes it; `false` turns it off, so a drop ends the session. |
+65 -6
View File
@@ -1,24 +1,68 @@
import type { PduObject } from './pdu.ts'; import type { PduObject } from './pdu.ts';
import type { SmppLog } from './log.ts';
import { ExpiringGroups } from './expiring-groups.ts';
import { IdleWaiters } from './idle-waiters.ts'; import { IdleWaiters } from './idle-waiters.ts';
export type HeldMessagesOptions = {
log: SmppLog;
max: number;
timeout: number;
};
/** The peer's own sequence number, which is what our answer to this message will carry. */
function keyOf(pduObjs: PduObject[]): string | undefined {
const first = pduObjs[0];
return first ? String(first.seqNr) : undefined;
}
/** The messages handed to the application that it has not answered yet, held by their segments. */ /** The messages handed to the application that it has not answered yet, held by their segments. */
export class HeldMessages { export class HeldMessages {
private readonly held = new Set<PduObject[]>(); private readonly held: ExpiringGroups<PduObject[]>;
private readonly idleWaiters = new IdleWaiters(); private readonly idleWaiters = new IdleWaiters();
private readonly log: SmppLog;
constructor(options: HeldMessagesOptions) {
this.held = new ExpiringGroups({
max: options.max,
onSweep: () => { this.sweep(); },
timeout: options.timeout,
});
this.log = options.log;
}
/** An application that answers no message at all may not grow this without end. */
hold(pduObjs: PduObject[]): void { hold(pduObjs: PduObject[]): void {
this.held.add(pduObjs); const key = keyOf(pduObjs);
if (key === undefined) return;
if (this.held.full) {
const evicted = this.held.takeOldest();
if (evicted) {
this.log.warn('heldMessages - dropping the message held longest', { seqNr: evicted[0] });
}
}
this.held.set(key, pduObjs);
} }
/** Whether a drain is still waiting for this message to be answered. */ /** Whether a drain is still waiting for this message to be answered. */
has(pduObjs: PduObject[]): boolean { has(pduObjs: PduObject[]): boolean {
return this.held.has(pduObjs); const key = keyOf(pduObjs);
return key !== undefined && this.held.get(key) === pduObjs;
} }
release(pduObjs: PduObject[]): void { release(pduObjs: PduObject[]): void {
if (!this.held.delete(pduObjs)) return; const key = keyOf(pduObjs);
if (this.held.size === 0) this.idleWaiters.settle(); // Identity, not the key: a wrapped sequence number must not release someone else's message.
if (key === undefined || this.held.get(key) !== pduObjs) return;
this.held.delete(key);
this.settle();
} }
/** Drops every message: their segments went with the link, so no answer of ours correlates now. */ /** Drops every message: their segments went with the link, so no answer of ours correlates now. */
@@ -28,7 +72,22 @@ export class HeldMessages {
} }
/** Resolves 0 once every message has been answered, or with how many have not. */ /** Resolves 0 once every message has been answered, or with how many have not. */
idle(timeout: number, signal?: AbortSignal): Promise<number> { idle(timeout: number, signal: AbortSignal | undefined): Promise<number> {
return this.idleWaiters.wait(() => this.held.size, timeout, signal); return this.idleWaiters.wait(() => this.held.size, timeout, signal);
} }
private sweep(): void {
const expired = this.held.takeExpired();
if (expired.length === 0) return;
this.log.warn('heldMessages - messages the application never answered', {
messages: expired.length,
});
this.settle();
}
private settle(): void {
if (this.held.size === 0) this.idleWaiters.settle();
}
} }
+7 -2
View File
@@ -31,7 +31,7 @@ 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;
private readonly held = new HeldMessages(); private readonly held: HeldMessages;
private readonly log: SmppLog; private readonly log: SmppLog;
private readonly onRequest: OnRequest | undefined; private readonly onRequest: OnRequest | undefined;
private readonly reassembler: Reassembler; private readonly reassembler: Reassembler;
@@ -42,6 +42,11 @@ export class IncomingRequests {
constructor(options: IncomingRequestsOptions) { constructor(options: IncomingRequestsOptions) {
this.dlrMerger = options.dlrMerger; this.dlrMerger = options.dlrMerger;
this.held = new HeldMessages({
log: options.log,
max: defaults.maxHeldMessages,
timeout: defaults.heldMessageTimeout,
});
this.log = options.log; this.log = options.log;
this.onRequest = options.onRequest; this.onRequest = options.onRequest;
this.reassembler = new Reassembler({ this.reassembler = new Reassembler({
@@ -96,7 +101,7 @@ export class IncomingRequests {
} }
/** 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): 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);
if (unanswered === 0) return {}; if (unanswered === 0) return {};
+1 -1
View File
@@ -35,7 +35,7 @@ export { uuidv7 } from './uuid.ts';
export type { BindType, ClientOptions } from './client.ts'; export type { BindType, ClientOptions } from './client.ts';
export type { Dlr, Receipt } from './dlr.ts'; export type { Dlr, Receipt } from './dlr.ts';
export type { SendRespOptions, Sms, SmsInput } from './sms.ts'; export type { SendDlrResult, SendRespOptions, Sms, SmsInput } from './sms.ts';
export type { ConcatInfo } from './udh.ts'; export type { ConcatInfo } from './udh.ts';
export type { Result, VoidResult } from './result.ts'; export type { Result, VoidResult } from './result.ts';
export type { SmppLog } from './log.ts'; export type { SmppLog } from './log.ts';
+14 -6
View File
@@ -24,6 +24,13 @@ function abortedBeforeSend(): Error {
return new Error('Aborted before the request was sent'); return new Error('Aborted before the request was sent');
} }
/** A response carries the request's sequence number, which only sendReturn() has. */
function misuse(input: PduObjectInput): Error | undefined {
return input.cmdName.endsWith('_resp')
? new Error(`Use sendReturn() for responses, not send(): ${input.cmdName}`)
: undefined;
}
/** Everything this end asks of the peer: which link carries it, how many at once, and the answer. */ /** Everything this end asks of the peer: which link carries it, how many at once, and the answer. */
export class OutgoingRequests { export class OutgoingRequests {
private readonly gate: LinkGate; private readonly gate: LinkGate;
@@ -67,6 +74,11 @@ export class OutgoingRequests {
/** Sends a request and resolves with the peer's response. */ /** Sends a request and resolves with the peer's response. */
request(input: PduObjectInput, options: SendOptions): Promise<Result<{ pduObj: PduObject }>> { request(input: PduObjectInput, options: SendOptions): Promise<Result<{ pduObj: PduObject }>> {
// Ahead of the drain, so a misuse is named as one rather than blamed on the shutdown.
const wrong = misuse(input);
if (wrong) return Promise.resolve({ err: wrong });
// A drain on a live link. A link that is down is the gate's answer, which says closed instead. // A drain on a live link. A link that is down is the gate's answer, which says closed instead.
if (this.draining && !this.linkDown()) { if (this.draining && !this.linkDown()) {
return Promise.resolve({ err: new Error('Session is shutting down') }); return Promise.resolve({ err: new Error('Session is shutting down') });
@@ -123,19 +135,15 @@ export class OutgoingRequests {
if (unfinished === 0) return {}; if (unfinished === 0) return {};
this.log.warn('session - shutting down with requests unfinished', { timeout, unfinished }); this.log.warn('outgoingRequests - shutting down with requests unfinished', { timeout, unfinished });
return { err: new Error(`Shut down with ${String(unfinished)} request(s) unfinished`) }; return { err: new Error(`Shut down with ${String(unfinished)} request(s) unfinished`) };
} }
/** Why a request cannot go out at all, as opposed to not yet. */ /** Why a request cannot go out at all, as opposed to not yet. */
private refuse(input: PduObjectInput, options: SendOptions): Error | undefined { private refuse(input: PduObjectInput, options: SendOptions): Error | undefined {
if (input.cmdName.endsWith('_resp')) {
return new Error(`Use sendReturn() for responses, not send(): ${input.cmdName}`);
}
// Before the gate and the window, or an aborted call waits for what it will never use. // Before the gate and the window, or an aborted call waits for what it will never use.
return options.signal?.aborted === true ? abortedBeforeSend() : undefined; return misuse(input) ?? (options.signal?.aborted === true ? abortedBeforeSend() : undefined);
} }
private async attempt(input: PduObjectInput, options: SendOptions): Promise<Attempt> { private async attempt(input: PduObjectInput, options: SendOptions): Promise<Attempt> {
+3
View File
@@ -101,8 +101,11 @@ export const undeclaredInterfaceVersion = 0x00;
export const defaults = { export const defaults = {
/** Receipts of a multipart message can be a working day apart, so the cap does the bounding. */ /** Receipts of a multipart message can be a working day apart, so the cap does the bounding. */
dlrMergeTimeout: 86_400_000, dlrMergeTimeout: 86_400_000,
/** The peer gave up on an unanswered message long before this; the bound is against growth. */
heldMessageTimeout: 300_000,
maxDelay: 30_000, maxDelay: 30_000,
maxDlrMerges: 1000, maxDlrMerges: 1000,
maxHeldMessages: 1000,
maxOutstanding: 10, maxOutstanding: 10,
maxReassembly: 1000, maxReassembly: 1000,
minDelay: 1000, minDelay: 1000,
+6 -6
View File
@@ -313,14 +313,14 @@ export class Session extends EventEmitter<SessionEvents> {
return { err: new Error('The session closed before the drain finished') }; return { err: new Error('The session closed before the drain finished') };
} }
return messages.err ? messages : requests; if (!messages.err) return requests;
if (!requests.err) return messages;
return { err: new Error(`${messages.err.message}; ${requests.err.message}`) };
} }
/** /** The application half's budget, which may never be "forever": nothing else ends that wait. */
* How long the drain waits for the application, which is the only thing that can end that wait.
* Neither timeout may hand it "forever": both are answers about a peer, and a peer is not what
* this half is waiting for.
*/
private answering(timeout: number): number { private answering(timeout: number): number {
if (timeout > 0) return timeout; if (timeout > 0) return timeout;
+25 -6
View File
@@ -3,11 +3,20 @@ import type { MessageState } from './defs/constants.ts';
import type { PduObject, PduObjectInput, TlvInput } from './pdu.ts'; import type { PduObject, PduObjectInput, TlvInput } from './pdu.ts';
import type { Result, VoidResult } from './result.ts'; import type { Result, VoidResult } from './result.ts';
import type { Session } from './session.ts'; import type { Session } from './session.ts';
import { UnansweredError } from './unanswered-error.ts';
import { consts } from './defs/constants.ts'; import { consts } from './defs/constants.ts';
import { receiptCodes } from './dlr.ts'; import { receiptCodes } from './dlr.ts';
import { smppDate } from './message.ts'; import { smppDate } from './message.ts';
import { uuidv7 } from './uuid.ts'; import { uuidv7 } from './uuid.ts';
/** Both fields hold what the peer took, so a partial failure names what is already receipted. */
export type SendDlrResult = {
err?: Error;
pduObjs: PduObject[];
/** Segments that went out unanswered. The peer may have taken them, so sending again may duplicate. */
unanswered: number;
};
export type SendRespOptions = { export type SendRespOptions = {
/** The id the peer correlates a later delivery receipt by. Defaults to a generated UUID v7. */ /** The id the peer correlates a later delivery receipt by. Defaults to a generated UUID v7. */
smsId?: string; smsId?: string;
@@ -25,7 +34,7 @@ export type Sms = {
message: string; message: string;
pduObjs: PduObject[]; pduObjs: PduObject[];
/** Sends a delivery report back to the sender. Defaults to DELIVERED. */ /** Sends a delivery report back to the sender. Defaults to DELIVERED. */
sendDlr: (status?: MessageState) => Promise<Result<{ pduObjs: PduObject[] }>>; sendDlr: (status?: MessageState) => Promise<SendDlrResult>;
/** Answers every segment. Part of the protocol, not optional. Defaults to ESME_ROK. */ /** Answers every segment. Part of the protocol, not optional. Defaults to ESME_ROK. */
sendResp: (options?: SendRespOptions) => Promise<VoidResult>; sendResp: (options?: SendRespOptions) => Promise<VoidResult>;
session: Session; session: Session;
@@ -132,9 +141,13 @@ async function sendDlr(
sms: Sms, sms: Sms,
send: SmsHandlers['send'], send: SmsHandlers['send'],
status: MessageState = 'DELIVERED', status: MessageState = 'DELIVERED',
): Promise<Result<{ pduObjs: PduObject[] }>> { ): Promise<SendDlrResult> {
if (!sms.session.bindAllows('deliver_sm')) { if (!sms.session.bindAllows('deliver_sm')) {
return { err: new Error('A transmitter-bound session does not carry deliver_sm') }; return {
err: new Error('A transmitter-bound session does not carry deliver_sm'),
pduObjs: [],
unanswered: 0,
};
} }
const total = sms.pduObjs.length; const total = sms.pduObjs.length;
@@ -154,12 +167,18 @@ async function sendDlr(
}); });
})); }));
const pduObjs: PduObject[] = []; const pduObjs: PduObject[] = [];
let failure: Error | undefined;
let unanswered = 0;
for (const one of sent) { for (const one of sent) {
if (one.err) return { err: one.err }; if (!one.err) {
pduObjs.push(one.pduObj); pduObjs.push(one.pduObj);
} else {
if (one.err instanceof UnansweredError) unanswered++;
failure ??= one.err;
}
} }
return { pduObjs }; return failure ? { err: failure, pduObjs, unanswered } : { pduObjs, unanswered };
} }
+65 -1
View File
@@ -12,6 +12,7 @@ import type { SmppLog } from '../src/log.ts';
import type { Sms } from '../src/sms.ts'; 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 { 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';
@@ -857,6 +858,43 @@ describe('LinkGate', () => {
}); });
}); });
// Goal 4: an application that answers nothing must not grow this for the life of the link.
describe('held message bounds', () => {
function message(seqNr: number): PduObject[] {
return [{
cmdId: 0x00000004,
cmdLength: 0,
cmdName: 'submit_sm',
cmdStatus: 'ESME_ROK',
cmdStatusId: 0,
params: { destination_addr: '46709771337', short_message: 'held', source_addr: '46701113311' },
seqNr,
tlvs: {},
}];
}
test('drops the message held longest rather than holding every one', async () => {
const held = new HeldMessages({ log: silentLog, max: 2, timeout: 10_000 });
const oldest = message(1);
held.hold(oldest);
held.hold(message(2));
held.hold(message(3));
assert.equal(held.has(oldest), false);
assert.equal(await held.idle(1, undefined), 2);
});
test('gives up on a message the application never answers', async () => {
const held = new HeldMessages({ log: silentLog, max: 10, timeout: 20 });
held.hold(message(1));
assert.equal(await held.idle(1, undefined), 1);
assert.equal(await held.idle(1000, undefined), 0);
});
});
describe('reassembly bounds', () => { describe('reassembly bounds', () => {
function segment(reference: number, part: number, total: number): PduObject { function segment(reference: number, part: number, total: number): PduObject {
const udh = Buffer.from([0x05, 0x00, 0x03, reference, total, part]); const udh = Buffer.from([0x05, 0x00, 0x03, reference, total, part]);
@@ -1108,10 +1146,36 @@ describe('graceful shutdown', () => {
const { smpp } = await submitInFlight(t, {}, { responseTimeout: 200, shutdownTimeout: 0 }); const { smpp } = await submitInFlight(t, {}, { responseTimeout: 200, shutdownTimeout: 0 });
const started = Date.now(); const started = Date.now();
const closed = await peerOf(smpp).close(); const closed = await peerOf(smpp).close();
const waited = Date.now() - started;
assert.ok(closed.err instanceof Error); assert.ok(closed.err instanceof Error);
assert.match(closed.err.message, /1 message\(s\) unanswered/); assert.match(closed.err.message, /1 message\(s\) unanswered/);
assert.ok(Date.now() - started < 2000); assert.ok(waited >= 190, `waited ${String(waited)} ms, so the fallback was not what bounded it`);
assert.ok(waited < 2000);
});
// leftOf() floors what is left at 1 ms: at 0 the request half would read "wait forever" instead.
test('still ends when the message half has spent the whole shutdown budget', async t => {
const { smpp } = await submitInFlight(t, {}, { shutdownTimeout: 100 });
const bound = peerOf(smpp);
// The client listens for no 'sms', so this one is never answered and stays in the window.
const unanswered = bound.send({
cmdName: 'submit_sm',
params: {
destination_addr: '46701113311',
short_message: 'nothing answers this',
source_addr: '46709771337',
},
});
const closed = await Promise.race([
bound.close(),
new Promise<{ err?: Error }>(resolve => {
setTimeout(() => { resolve({ err: new Error('close() never returned') }); }, 2000).unref();
}),
]);
assert.match(closed.err?.message ?? '', /1 message\(s\) unanswered; .*1 request\(s\) unfinished/);
assert.ok((await unanswered).err instanceof Error);
}); });
// The README's own listener answers and then sends its receipt, one turn later. Multipart, because // The README's own listener answers and then sends its receipt, one turn later. Multipart, because
+4 -3
View File
@@ -58,6 +58,7 @@ Rules the API follows:
| A draining `close()` and `unbind()`, bounded by `shutdownTimeout` or an abort | `test/session-extras.test.ts` | | A draining `close()` and `unbind()`, bounded by `shutdownTimeout` or an abort | `test/session-extras.test.ts` |
| A drain that also waits out the messages the application has not answered, with `sendDlr()` the one send that passes it | `test/session-extras.test.ts` | | A drain that also waits out the messages the application has not answered, with `sendDlr()` the one send that passes it | `test/session-extras.test.ts` |
| `OutgoingRequests`: the gate, the window, the pending map and the retry under one owner, told when a link comes up or goes down | `test/session-extras.test.ts`, `test/session.test.ts` | | `OutgoingRequests`: the gate, the window, the pending map and the retry under one owner, told when a link comes up or goes down | `test/session-extras.test.ts`, `test/session.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` |
| 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` |
@@ -135,9 +136,9 @@ session message is a change to every call site.
- [ ] **An `sms` listener that rejects before answering costs a whole `shutdownTimeout`.** - [ ] **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 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 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. the handler an `unknown[]` the `Sms` cannot be read out of without a cast, so nothing releases
Same cost as the `onRequest`-answers-nothing case that was declined, but reached by a bug until the message expires. Same cost as the `onRequest`-answers-nothing case that was declined,
rather than a policy. Raised by review, 2026-09-01. but reached by a bug rather than a policy. Raised by review, 2026-09-01.
- [ ] **A message the reconnect dropped is answered into the void, and reported as delivered.** - [ ] **A message the reconnect dropped is answered into the void, and reported as delivered.**
`teardown()` clears the held messages along with the inbound segments, which is right — but the `teardown()` clears the held messages along with the inbound segments, which is right — but the