diff --git a/AGENTS.md b/AGENTS.md
index 882bb24..55d0934 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -38,28 +38,28 @@ These are not preferences. Breaking one is a defect.
```
src/
index.ts Public surface. Named exports only, no default export.
+ bind-direction.ts The three bind types, which end of the link is which, what a bind carries, and how one is recorded
client.ts client() -> { err, session }
server.ts server() -> { err, server }, server owns the listener + close()
- session.ts Session: the socket's life, dispatch, events, and the collaborators below
+ session.ts Session: its life (open, closing, closed), the current Link, dispatch and events
sms.ts The live handle emitted as the 'sms' event (sendResp/sendDlr)
concat.ts How a PDU says it is a segment: its UDH, or the sar_* TLVs
dlr.ts Delivery receipts: text and TLV parsing, receipt status codes
dlr-merger.ts DlrMerger: per-segment receipts counted into one MessageDlr
+ drain.ts drain(): a shutdown's wait for the messages, then the requests, on one budget
error-from.ts An untyped value as error material: errorFrom() an Error, namedValue() a name
expiring-groups.ts ExpiringGroups: the capped, weighed, expiring store DlrMerger, HeldMessages and Reassembler share
- held-messages.ts HeldMessages: a message from its `sms` event to its answer, capped and expiring, one MessageHold each
- idle-waiters.ts IdleWaiters: waiting for a count to fall to zero, and what is left of a budget
+ held-messages.ts HeldMessages: the messages one link handed to the application, one HeldMessage each, and its six exits
+ idle-waiters.ts IdleWaiters: waiting for a count to fall to zero
incoming-requests.ts Every request the peer sends: messages, receipts, links, unknown commands
- link-life.ts LinkLife: whether the link lives, and where a request waits for the next one
- link-timers.ts LinkTimers: the enquire_link heartbeat and the idle timeout
+ link.ts Link: one socket — the PDUs read off it, its timers, the requests waiting on it, what arrived on it — closed once
log.ts SmppLog, the logger contract, and silentLog — the default
message.ts Encoding detection, splitting, bit counting, SMPP date formatting
message-body.ts Where an inbound body is: short_message, or the message_payload TLV
- outgoing-requests.ts OutgoingRequests: the window, the pending map and the retry
+ outgoing-requests.ts OutgoingRequests: the window, the wait for a link and the retry
pdu.ts pduToObj / objToPdu / pduReturn — synchronous, result-returning
pdu-framer.ts PduFramer: a byte stream cut into complete PDUs
pdu-refusal.ts A PDU the codec would not read, and the answer SMPP names for it
- pdu-transport.ts PduTransport: the socket a session reads complete PDUs off
pending-requests.ts PendingRequests: sequence numbers, correlation, timeout, abort
reassembly.ts Reassembler: capped, expiring multipart groups
reconnect-loop.ts ReconnectLoop: backoff, retry timer, stopped-ness
@@ -67,7 +67,7 @@ src/
retained-pdu.ts A PDU copied off the wire so holding it pins nothing else, and what holding it costs
send-sms.ts submitSms composition and the submitSmParams builder
send-window.ts SendWindow: the maxOutstanding semaphore
- session-options.ts SessionOptions, ReconnectOptions, bind direction and the session defaults
+ session-options.ts SessionOptions, ReconnectOptions and the session defaults
sms-id.ts Message ids: the peer's notation, the - a segment gets, which response carries one
udh.ts User data header: its length, the concatenation fields of a long SMS and their reference
unanswered-error.ts UnansweredError: it went out and no answer came back
@@ -310,7 +310,7 @@ this is not a changelog.
- Locality work comes before other work until a scoring run reads 7.0.
- A listener that rejects is routed by Node's `captureRejections`, not by hand-dispatching.
-- The four-line abort dance is copied across `LinkLife`, `IdleWaiters`, `PendingRequests` and
+- The four-line abort dance is copied across `OutgoingRequests`, `IdleWaiters`, `PendingRequests` and
`SendWindow` rather than extracted.
- `SmppLog` is a five-method contract this library declares, not a dependency.
- The TLS tests build their own self-signed certificate in DER
diff --git a/DESIGN.md b/DESIGN.md
new file mode 100644
index 0000000..d819b39
--- /dev/null
+++ b/DESIGN.md
@@ -0,0 +1,65 @@
+# Draft A: a `Link` per socket, a three-word session life, one drain
+
+The confusion came from *one* socket's state being spread over a mutable phase (`LinkLife`), a
+generation counter, a separate stopped flag, and five files. The redesign gives every socket an
+object of its own and lets identity do what the counter and the phase did.
+
+## Structure and ownership
+
+| Module | Owns |
+| --- | --- |
+| `link.ts` — `Link` | One socket: framer, enquire/idle timers, `pending` (responses owed on it), `held` (messages handed out on it), `reassembler` (its half-arrived groups). `canCarry()` = bound, not closed, socket alive. `close()` runs once and ends all of it. |
+| `session.ts` — `Session` | `life: 'open' \| 'closing' \| 'closed'` and `link: Link` (the latest, closed or not). The four transitions sit together: `openLink()`, `comeBackUp()`, `linkLost()`, `end()`. Nothing else writes `life` or `link`; every life event is emitted from one of them. |
+| `outgoing-requests.ts` | The send window, the wait for a carrying link, the retry. Reads the session through `LinkView` (`closing`, `current`, `nextExpected`) and keeps no copy. |
+| `incoming-requests.ts` | A per-session router, `handle(link, pduObj)`: what a request touches is the link it arrived on. |
+| `held-messages.ts` | `HeldMessages` (per link) and `HeldMessage`, with the six exits listed once on the class. Owns the at-bound log hysteresis. |
+| `drain.ts` | `drain(waits, budget, signal)`: messages first, then requests, one deadline, the never-forever rule for the application half, the combined error. |
+| `bind-direction.ts` | What `session-options.ts` held that was not an option: bind types, `LinkEnd`, `standsInFor`, `bindCarries`, `checkedBind`. |
+
+## How each flow reads now
+
+**Held message, six exits** (all in `held-messages.ts`): 1 `sendResp()` reached the wire →
+`HeldMessage.answered()` → release a turn later. 2 last listener rejects → `Session`'s
+`captureRejectionSymbol` → `this.link.held.rejected(sms)` → `listenerGaveUp()`. 3 no listener or one
+threw → `offer()` sees `emit()` false → `release()`. 4 re-used sequence number → `offer()` replaces
+the entry. 5 deadline → `sweep()`. 6 link gone → `Link.close()` → `held.clear()`, which also makes
+`lostLink()` true for every `sendResp()` after it. The `WeakMap` stays (a rejecting listener hands the
+`Sms` back as `unknown`), but the route is two hops, not four, and there is no generation counter.
+
+**Shutdown** (`close()`/`unbind()`): `drain()` sets `life = 'closing'`, stops the loop, and if the
+link can carry, waits through `drain.ts`; `end()` then sets `'closed'`, closes the link, clears the
+merges, releases the link waiters with "closed", emits `close`. Both are idempotent by state, so a
+listener re-entering `close()` mid-teardown changes nothing.
+
+**Link loss** (`linkLost(link)`): ignored unless `link` is the current one and open. Emits the error
+it came with, decides `disconnected` vs `close` from `life === 'open' && loop` *before* `link.close()`
+(a listener may close the session inside it), then either emits `disconnected` and schedules the
+loop, or calls `end()`.
+
+**Reconnect** (`comeBackUp()`): a new `Link` with `bound: false` becomes `this.link`; the bind goes
+out through `send()` because a bind is let onto the current link. A failed bind is `linkLost(link)`.
+A `close()` that landed meanwhile shows as `link.isClosed()`. Otherwise `markBound()`,
+`outgoing.linkBound()` (releases held sends), `reconnected`.
+
+**A send** (`requestDuringDrain`): `carrier()` returns the current link once `canCarry()`, waits
+while `nextExpected()`, else refuses as closed; a write that reached no socket loops for the next
+link. A destroyed-but-not-yet-closed socket cannot spin: `canCarry()` is false for it and the wait
+ends only on `linkBound()`/`over()`.
+
+## Deleted
+
+`link-life.ts` (phase + stopped + generation + waiters), `link-timers.ts` and `pdu-transport.ts`
+(both folded into `Link`), `IncomingRequests.listenerRejected/drain/clear`, `OutgoingRequests.
+linkLost/deliver/settleRefused/canCarry/drain`, `Session.stop/emitClose/teardown/onClose/attach/
+resetTimers/transportFor`, `leftOf` (private to `drain.ts`), the `refusing` flag (now
+`HeldMessages.atBound`), and `MessageHold` (now `HeldMessage`). Three modules out, three in
+(`link.ts`, `drain.ts`, `bind-direction.ts`); `src/` stays at 35 top-level files.
+
+## Tests
+
+`docker compose run --rm node npm test`: lint and typecheck clean, **515 tests, 515 pass, 0 fail**
+(same count as before). Changed, all in `test/session-extras.test.ts`, none weakened:
+
+- Helpers `incomingOn()`/`heldOn()`: build a `Link` (new `linkOn()`) instead of a `LinkLife`; `handle(pdu)` and `close()` go through that link. Assertions untouched.
+- "drops a message whose link went while onRequest was still running": `link.drop()` → `link.close()`; the second message goes through a fresh link, since a closed link never delivers (the old generation counter let the same object be re-used).
+- The six `LinkLife` unit tests, whose subject no longer exists, became five `OutgoingRequests waiting for a link` tests and one `Link` test asserting the same behaviour on the new objects: budget already spent when a link is released, the un-`unref`'d timer, abort while waiting, waits only while a link is on its way and is told "closed" otherwise, the drain refusal only while a link could carry; and `Link.close()` once, settling what waited on it and making `canCarry()` final.
diff --git a/docs/decisions.md b/docs/decisions.md
index c245097..802e34a 100644
--- a/docs/decisions.md
+++ b/docs/decisions.md
@@ -561,7 +561,7 @@ rule and an index of the titles below.
- **A stream this library cannot frame is a dead link; one PDU it cannot parse is not.**
Maintainer's call, 2026-08-31, narrowed 2026-09-05 via the interop plan: a `command_length` below
16 or above `maxPduLength` leaves nothing that can say where the next PDU starts, so it tears the
- link down through `teardown()` and the reconnect loop retries it on a fresh socket with a fresh
+ link down through `Link.close()` and the reconnect loop retries it on a fresh `Link` with a fresh
framer. Every other codec failure honoured `command_length`, so the stream is still in sync and
the next PDU starts where it says — tearing the link down there cost one peer half its receipts
and its MO to a reconnect loop (`interop-tests/findings/01-smscsim.md`), and left the peer waiting
@@ -668,7 +668,7 @@ rule and an index of the titles below.
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 application is stuck, so it may not block on the application coming unstuck. That half falls
- back to `responseTimeout`, the same answer `LinkLife`'s hold already takes — and to that
+ back to `responseTimeout`, the same answer a send held for a link already takes — and to that
option's default where it is 0 as well, since neither option is an answer about the application.
- **What the application holds unanswered is capped on constants, and a message past the cap is
@@ -694,10 +694,10 @@ rule and an index of the titles below.
error, the one an SMSC retries on (goal 3).
- **A reconnect keeps the delivery-receipt merges; everything else the link held is dropped.**
- `onDelivery()` answers each receipt before the group it belongs to is complete, and `teardown()`
+ `onDelivery()` answers each receipt before the group it belongs to is complete, and `Link.close()`
runs on every path — an idle timeout and a failed rebind, not only `close()` — so clearing the
merges there loses receipts no peer has a reason to send again. They are cleared where the session
- is over instead. Inbound segments stay in `teardown()`: a concatenation reference is the
+ is over instead. Inbound segments go with the link: a concatenation reference is the
peer's own counter, so a half-arrived group kept across a drop would take a later message's
segments as readily as the rest of its own, and goal 2 will not hand the application a message
assembled that way. What goes there is traffic already answered, which is why each group reaches
@@ -705,7 +705,7 @@ rule and an index of the titles below.
- **The bind state is the session's, `bound()` alone writes it, and it holds through a reconnect's
gap.** Maintainer's call, 2026-09-28. `client()` and `server()` record their bind through
- `bound()`, the call a hand-wired session makes, so the state has one writer — goal 8. Clearing the state at `teardown()` was rejected: `bindAllows()` and
+ `bound()`, the call a hand-wired session makes, so the state has one writer — goal 8. Clearing the state when a link closes was rejected: `bindAllows()` and
`acceptsOptionalParams()` then answer yes to everything while the link is down, so a
receiver-bound client queues a `submit_sm` the peer refuses and a receipt built then carries TLVs a
pre-3.4 peer must not get — goal 4. Valid while the reconnect loop binds again with the same bind
@@ -752,10 +752,10 @@ rule and an index of the titles below.
- **One owner decides whether a link can carry a request, and a bind is what makes it one.**
Maintainer's call, 2026-09-01, extended 2026-09-28; goal 1, since a send on a link not yet bound
- comes back `ESME_RINVBNDSTS`. `LinkLife` is told what happened and never reads back into the
- session; every other collaborator reads it and keeps no copy. Rejected: gating on the socket being
- attached, which admits a send one round trip before the bind is answered, and collaborators that
- ask the session, which answered the same question two ways at admit and at release.
+ comes back `ESME_RINVBNDSTS`. `Session` owns its life and the current `Link`, and `Link.canCarry()`
+ is the one answer; the senders read it through `LinkView` and keep no copy. Rejected: gating on the
+ socket being attached, which admits a send one round trip before the bind is answered, and
+ collaborators that ask the session, which answered the same question two ways at admit and at release.
`ReconnectLoop.halted` is the one other flag, because `client()` also runs a loop with no session
behind it for `fromStart`; a session's loop is stopped by `Session.stop()` alone.
@@ -777,7 +777,7 @@ rule and an index of the titles below.
handlers normalise through `errorFrom()` rather than inline — a route out of the handler would land
on a bare `process.nextTick` with nothing to catch it.
-- **The four-line abort dance is copied across `LinkLife`, `IdleWaiters`, `PendingRequests` and
+- **The four-line abort dance is copied across `OutgoingRequests`, `IdleWaiters`, `PendingRequests` and
`SendWindow` rather than extracted.** Architecture review, 2026-09-06: pre-check `aborted`, attach
`{ once: true }`, detach on settle, leave the registry. What differs at each site is the registry
and what settling means — a FIFO handing over a slot, a set released together, a map keyed by
@@ -797,7 +797,7 @@ rule and an index of the titles below.
- **`src/` stays flat until a module has to move for another reason.** Architecture review,
2026-09-06: the grouping the [file map](../AGENTS.md#architecture) already implies — `wire/` for `pdu*` and `defs`,
- `link/` for `link-*`, `reconnect-*`, `pdu-transport` and `send-window`, `messages/` for `sms*`,
+ `link/` for `link`, `reconnect-loop`, `outgoing-requests` and `send-window`, `messages/` for `sms*`,
`dlr*`, `message*`, `reassembly` and `udh` — rewrites every import for no change to
`dist/index.js`, the one published entry. Valid while that map is what a reader navigates by.
diff --git a/src/bind-direction.ts b/src/bind-direction.ts
new file mode 100644
index 0000000..9f62ffc
--- /dev/null
+++ b/src/bind-direction.ts
@@ -0,0 +1,73 @@
+import type { Result } from './result.ts';
+import { quoted } from './error-from.ts';
+
+export const bindCommands: readonly string[] = [
+ 'bind_receiver',
+ 'bind_transceiver',
+ 'bind_transmitter',
+];
+
+export type BindType = 'receiver' | 'transceiver' | 'transmitter';
+
+/** Which end of the link a session is. Only `server()` is the SMSC; everything else is the ESME. */
+export type LinkEnd = 'esme' | 'smsc';
+
+/** SMPP 3.4: a peer that declares no version at all is one from before optional parameters. */
+export const undeclaredInterfaceVersion = 0x00;
+
+export type SessionBind = { as: BindType; peerVersion: number };
+
+export function bindTypeFromCommand(cmdName: string): BindType | undefined {
+ if (cmdName === 'bind_receiver') return 'receiver';
+ if (cmdName === 'bind_transceiver') return 'transceiver';
+ if (cmdName === 'bind_transmitter') return 'transmitter';
+
+ return undefined;
+}
+
+/**
+ * Which message-carrying command an inbound one stands in for. Every command but `data_sm` names
+ * its own direction; that one travels either way, so the end it arrived at is what says.
+ */
+export function standsInFor(cmdName: string, linkEnd: LinkEnd): string {
+ if (cmdName !== 'data_sm') return cmdName;
+
+ return linkEnd === 'smsc' ? 'submit_sm' : 'deliver_sm';
+}
+
+/**
+ * Whether a bind direction carries a command at all. A receiver-bound ESME submits nothing and a
+ * transmitter-bound one is delivered nothing, whichever end of the link is looking. A session that
+ * has not bound carries everything, since nothing has declared a direction yet.
+ */
+export function bindCarries(
+ bindType: BindType | undefined,
+ cmdName: string,
+ linkEnd: LinkEnd,
+): boolean {
+ const carried = standsInFor(cmdName, linkEnd);
+
+ if (bindType === 'receiver') return carried !== 'submit_sm';
+ if (bindType === 'transmitter') return carried !== 'deliver_sm';
+
+ return true;
+}
+
+function isBindType(value: unknown): value is BindType {
+ return typeof value === 'string' && bindTypeFromCommand(`bind_${value}`) !== undefined;
+}
+
+/** A bind as `Session.bound()` records it: undefined declares no version, which is pre-3.4. */
+export function checkedBind(bindType: unknown, declaredVersion: unknown): Result<{ bind: SessionBind }> {
+ if (!isBindType(bindType)) {
+ return { err: new Error(`bindType must be receiver, transceiver or transmitter, the bind command's name without "bind_", got ${quoted(bindType)}`) };
+ }
+
+ if (declaredVersion === undefined) return { bind: { as: bindType, peerVersion: undeclaredInterfaceVersion } };
+
+ if (typeof declaredVersion !== 'number' || !Number.isInteger(declaredVersion) || declaredVersion < 0 || declaredVersion > 0xFF) {
+ return { err: new Error(`declaredVersion must be an integer 0-255, the interface_version param or the sc_interface_version TLV's tagValue, or undefined where the peer declared none, got ${quoted(declaredVersion)}`) };
+ }
+
+ return { bind: { as: bindType, peerVersion: declaredVersion } };
+}
diff --git a/src/client.ts b/src/client.ts
index 6b7299c..28d9fc9 100644
--- a/src/client.ts
+++ b/src/client.ts
@@ -1,6 +1,7 @@
import type { ConnectionOptions } from 'node:tls';
import type { Result, VoidResult } from './result.ts';
-import type { BindType, ReconnectOptions } from './session-options.ts';
+import type { BindType } from './bind-direction.ts';
+import type { ReconnectOptions } from './session-options.ts';
import type { SmppLog } from './log.ts';
import type { SmsIdFormat } from './sms-id.ts';
import type { Socket } from 'node:net';
diff --git a/src/drain.ts b/src/drain.ts
new file mode 100644
index 0000000..9bd407a
--- /dev/null
+++ b/src/drain.ts
@@ -0,0 +1,57 @@
+import type { SmppLog } from './log.ts';
+import type { VoidResult } from './result.ts';
+import { defaults } from './session-options.ts';
+
+export type DrainBudget = {
+ responseTimeout: number;
+ /** 0 waits forever for the requests; the messages then fall back to `responseTimeout`. */
+ shutdownTimeout: number;
+};
+
+/** Each resolves 0 once nothing is left, or with what still is when the timeout or the signal cuts it short. */
+export type DrainWaits = {
+ /** Messages handed to the application and not yet answered. */
+ messages: (timeout: number, signal: AbortSignal | undefined) => Promise;
+ /** Requests on the wire or queued behind the send window. */
+ requests: (timeout: number, signal: AbortSignal | undefined) => Promise;
+};
+
+/** What is left of a budget, in the shape a wait takes it: 0 waits forever. */
+function leftOf(deadline: number): number {
+ return deadline === 0 ? 0 : Math.max(1, deadline - Date.now());
+}
+
+/** The application's half may never be "forever": nothing else ends that wait. */
+function messagesBudget(budget: DrainBudget): number {
+ if (budget.shutdownTimeout > 0) return budget.shutdownTimeout;
+
+ return budget.responseTimeout > 0 ? budget.responseTimeout : defaults.responseTimeout;
+}
+
+/** Waits out what a shutdown owes the peer: the messages first, then the requests, on one budget. */
+export async function drain(
+ waits: DrainWaits,
+ budget: DrainBudget,
+ signal: AbortSignal | undefined,
+ log: SmppLog,
+): Promise {
+ const deadline = budget.shutdownTimeout > 0 ? Date.now() + budget.shutdownTimeout : 0;
+ const problems: string[] = [];
+
+ // Messages first: answering one can put a receipt on the wire; nothing on the wire produces a message.
+ const unanswered = await waits.messages(messagesBudget(budget), signal);
+
+ if (unanswered > 0) {
+ log.warn('drain - shutting down with messages unanswered', { unanswered });
+ problems.push(`Shut down with ${String(unanswered)} message(s) unanswered`);
+ }
+
+ const unfinished = await waits.requests(leftOf(deadline), signal);
+
+ if (unfinished > 0) {
+ log.warn('drain - shutting down with requests unfinished', { unfinished });
+ problems.push(`Shut down with ${String(unfinished)} request(s) unfinished`);
+ }
+
+ return problems.length === 0 ? {} : { err: new Error(problems.join('; ')) };
+}
diff --git a/src/error-from.ts b/src/error-from.ts
index 5d77236..f5b3ff9 100644
--- a/src/error-from.ts
+++ b/src/error-from.ts
@@ -15,3 +15,8 @@ const printable: readonly string[] = ['boolean', 'number', 'string'];
export function namedValue(value: unknown): string {
return printable.includes(typeof value) ? String(value) : typeof value;
}
+
+/** namedValue() with a string in quotes, so an empty one and a wrong one both show. */
+export function quoted(value: unknown): string {
+ return typeof value === 'string' ? JSON.stringify(value) : namedValue(value);
+}
diff --git a/src/held-messages.ts b/src/held-messages.ts
index b9e740e..0d89d8b 100644
--- a/src/held-messages.ts
+++ b/src/held-messages.ts
@@ -1,4 +1,3 @@
-import type { LinkLife } from './link-life.ts';
import type { PduObject, PduObjectInput } from './pdu.ts';
import type { Result } from './result.ts';
import type { Session } from './session.ts';
@@ -10,12 +9,12 @@ import { createSms } from './sms.ts';
import { retainedOctets } from './retained-pdu.ts';
export type HeldMessagesOptions = {
- link: LinkLife;
log: SmppLog;
max: number;
maxOctets: number;
/** Injected so expiry can be exercised without a wall clock. */
now?: (() => number) | undefined;
+ /** A send the shutdown drain lets through, for the receipt of a message it is waiting on. */
sendPastDrain: SmsHandlers['send'];
session: Session;
timeout: number;
@@ -28,31 +27,30 @@ function keyOf(pduObjs: PduObject[]): string | undefined {
return first ? String(first.seqNr) : undefined;
}
-type HoldRoute = Pick;
-
/**
- * One message offered to the application, and the handlers its `Sms` answers through. A drain
- * waits on it until the first of: `answered()`, every listener that took it rejecting, no listener
- * taking it or one throwing, a later message on its sequence number, its deadline, or the link going.
+ * One message offered to the application, and what its `Sms` answers through. It is held until
+ * the first of six exits, each a method here or on `HeldMessages`:
+ * 1. `answered()`: `sendResp()` put the response on the wire, a turn later.
+ * 2. `listenerGaveUp()` from the last listener that took it and rejected.
+ * 3. `release()` at once, from `offer()`: no listener took it, or one threw.
+ * 4. `offer()` of a later message on the same sequence number replaces it.
+ * 5. `sweep()`: it passed its deadline.
+ * 6. `clear()`: the link it arrived on went, so nothing correlates its answer now.
*/
-export class MessageHold implements SmsHandlers {
- private readonly generation: number;
- private readonly heldMessages: HeldMessages;
+export class HeldMessage implements SmsHandlers {
+ private readonly held: HeldMessages;
private readonly pduObjs: PduObject[];
- private readonly route: HoldRoute;
private working: number;
- constructor(heldMessages: HeldMessages, route: HoldRoute, pduObjs: PduObject[], listeners: number) {
- this.generation = route.link.generation();
- this.heldMessages = heldMessages;
+ constructor(held: HeldMessages, pduObjs: PduObject[], listeners: number) {
+ this.held = held;
this.pduObjs = pduObjs;
- this.route = route;
this.working = listeners;
}
/** Whether a drain is still waiting for this message to be answered. */
isHeld(): boolean {
- return this.heldMessages.holds(this.pduObjs);
+ return this.held.holds(this.pduObjs);
}
/** A turn later, so a `sendDlr()` called straight after `sendResp()` still goes out past a drain. */
@@ -61,7 +59,7 @@ export class MessageHold implements SmsHandlers {
}
lostLink(): boolean {
- return this.route.link.generation() !== this.generation;
+ return this.held.isGone();
}
/** A rejection leaves the other listeners running, so only the last one to fail gives the message up. */
@@ -71,26 +69,30 @@ export class MessageHold implements SmsHandlers {
if (this.working <= 0) this.answered();
}
- /** At once, for a message nobody took or a listener threw on: that is not work a shutdown can wait for. */
release(): void {
- this.heldMessages.release(this.pduObjs);
+ this.held.release(this.pduObjs);
}
/** A receipt for a message still held is what a drain waits for, so it goes out past the drain. */
send(input: PduObjectInput): Promise> {
- return this.isHeld() ? this.route.sendPastDrain(input) : this.route.session.send(input);
+ return this.isHeld() ? this.held.sendPastDrain(input) : this.held.session.send(input);
}
}
-/** The messages handed to the application that it has not answered yet, held by their segments. */
+/** The messages one link handed to the application that it has not answered yet. */
export class HeldMessages {
+ readonly sendPastDrain: SmsHandlers['send'];
+ readonly session: Session;
+
private readonly held: ExpiringGroups;
private readonly idleWaiters = new IdleWaiters();
private readonly log: SmppLog;
+ private readonly max: number;
private readonly maxOctets: number;
/** A rejecting listener hands the message back as an `unknown`, so its hold is found by identity. */
- private readonly offered = new WeakMap