576b713af4
Test / lint (pull_request) Successful in 23s
Test / test (18) (pull_request) Successful in 30s
Test / test (20) (pull_request) Successful in 31s
Test / test (22) (pull_request) Successful in 31s
Test / test (24) (pull_request) Successful in 30s
Test / test (26) (pull_request) Successful in 30s
Mirror / push (push) Successful in 5s
1972 lines
80 KiB
Diff
1972 lines
80 KiB
Diff
diff --git a/AGENTS.md b/AGENTS.md
|
|
index 882bb24..244455b 100644
|
|
--- a/AGENTS.md
|
|
+++ b/AGENTS.md
|
|
@@ -38,6 +38,7 @@ These are not preferences. Breaking one is a defect.
|
|
```
|
|
src/
|
|
index.ts Public surface. Named exports only, no default export.
|
|
+ bind-direction.ts What a bind declares, and which commands its direction carries at either end
|
|
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
|
|
@@ -45,17 +46,17 @@ src/
|
|
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(): the shutdown's two waits and their budgets, and IdleWaiters, the wait on a count
|
|
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: a message from its `sms` event to its answer, and the six ways a hold ends
|
|
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-life.ts LinkLife: the link's life as one transition table, and where a request waits for the next link
|
|
link-timers.ts LinkTimers: the enquire_link heartbeat and the idle timeout
|
|
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 pending map and the carry onto the next link
|
|
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
|
|
@@ -67,7 +68,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, their checks and the session defaults
|
|
sms-id.ts Message ids: the peer's notation, the <base>-<n> 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
|
|
diff --git a/DESIGN.md b/DESIGN.md
|
|
new file mode 100644
|
|
index 0000000..b4c948e
|
|
--- /dev/null
|
|
+++ b/DESIGN.md
|
|
@@ -0,0 +1,56 @@
|
|
+# Draft B: the session's life as one table, the hold as one file, the drain as one function
|
|
+
|
|
+## Structure and who owns what
|
|
+
|
|
+| Module | Owns |
|
|
+| --- | --- |
|
|
+| `link-life.ts` | The link's phase (`up`, `binding`, `down`, `closed`) and the `stopping` flag. `transition(event)` is the whole lifecycle: one switch, five events (`attached`, `bound`, `lost`, `stopping`, `closed`), returning the effects the session runs in order (`dropLink`, `emitDisconnected`, `scheduleReconnect`, `emitClose`, `linkUp`, `stopReconnect`). Also the waiters for a request with no link, released by the same transitions. |
|
|
+| `session.ts` | Wiring, and the imperative shell: `apply(event)` runs `link.transition(event)` and then `run(effect)`, a switch that names every side effect the lifecycle has. No `end`/`stop`/`emitClose`/`teardown`/`onClose` — every path in (socket close, unreadable stream, idle timeout, failed rebind, `close()`, `unbind()`) is one `apply()` call. |
|
|
+| `held-messages.ts` | The whole held-message flow: the store, the `Sms` creation, the `sms` emit, and each of the six exits as a numbered method. Owned by `Session` directly, so a rejected listener is routed `Session → HeldMessages` in one hop. The hysteresis logging (`refusing`) moved here from `IncomingRequests`: `full()` decides and logs. |
|
|
+| `drain.ts` | `drain()`: the shutdown's two waits, both budgets, the one turn given to the application, and the error text. `IdleWaiters` lives here too, as the primitive the two stores wait with. |
|
|
+| `incoming-requests.ts` | Routing only: the hook, the bind gate, the dispatch per command, reassembly. It is handed `held` and never constructs, counts or drains it. |
|
|
+| `outgoing-requests.ts` | `request()` (refused after `stopping`), `requestPastDrain()` (a receipt), and `carry()`: a recursion in place of `for (;;)`, one attempt per link, the same budget throughout. `idle()` reports a count; the drain owns the words. |
|
|
+| `bind-direction.ts` | `bindCommands`, `BindType`, `LinkEnd`, `standsInFor`, `bindCarries`, `checkedBind`, split out of `session-options.ts`, which now holds option types, their checks and the defaults. |
|
|
+
|
|
+`hold` now has one meaning: none. `LinkLife.budget()` is a request's budget, `HeldMessages` keeps messages, `MessageHold` is gone.
|
|
+
|
|
+## How each exit reads now
|
|
+
|
|
+A hold is `Held = { key, pduObjs, working }` in `HeldMessages`; `offer()` builds the `Sms` with three closures and emits it.
|
|
+
|
|
+1. **Answered** — `sendResp()` puts the response on the wire, calls `answered()`, which is `release(held)`. Synchronous, no `setImmediate`.
|
|
+2. **Every listener rejected** — `Session[captureRejectionSymbol]` calls `held.listenerRejected(sms)`; the `WeakMap` finds the `Held`, `working--`, the last one releases.
|
|
+3. **No listener, or one threw** — `session.emit('sms')` returned false, so `offer()` releases at once.
|
|
+4. **Re-used sequence number** — `keep()` replaces the entry; the old `Held` fails `release()`'s identity check and can free nothing.
|
|
+5. **Deadline** — `sweep()`, before each `keep()` and on the store's timer.
|
|
+6. **Link gone** — `clear()`, run by the `dropLink` effect.
|
|
+
|
|
+The receipt that used to depend on the deferred release is now the drain's business: `sendDlr()` always sends through `requestPastDrain()` (a receipt answers a message the drain waits on, whenever it is sent), and `drain()` waits one `setImmediate` turn after the last message is answered before reading the window, so a receipt sent straight after the answer is in the window by then. One line, in the one place that waits on the application.
|
|
+
|
|
+**Shutdown** — `close()`: `apply('stopping')` (effect `stopReconnect`, before the first await) → `drain()`: messages half on `shutdownTimeout` or the `responseTimeout` fallback, one turn, requests half on what is left → `apply('closed')`: `dropLink` if a socket is attached, then `emitClose`. `unbind()` is the same with the unbind request between the drain and `closed`.
|
|
+
|
|
+**Reconnect** — socket close, unreadable stream, idle timeout and a failed rebind all `apply('lost')`. While retrying: `down`, effects `dropLink`, `emitDisconnected`, `scheduleReconnect`. Not retrying: `dropLink`, `emitClose`, phase `closed`. The loop's `comeBackUp()` is `attach` → `apply('attached')` → bind → `apply('bound')`, which is `linkUp` (release held sends, timers, `reconnected`), or, if `stopping` landed meanwhile, the `lost` path. Re-entrancy: the phase is updated before any effect runs, so a listener that calls `close()` from `disconnected` sees `down`, and the remaining effects are no-ops.
|
|
+
|
|
+## Deleted
|
|
+
|
|
+- `MessageHold` class, `SmsHandlers`' `isHeld` branch, the `setImmediate` in `answered()`, `sendPastDrain` plumbing through `IncomingRequests`.
|
|
+- `Session.end()`, `stop()`, `emitClose()`, `teardown()`, `onClose()`, `resetTimers()`, `attach()`; `LinkLife.drop()`'s returned event name and `end()`'s boolean, `isUp`/`isAttached`/`isOver`/`isStopped`/`retrying` as public predicates (`carries`, `attached`, `isStopping`, `awaitsNextLink`, `refusal` remain, all reading `phase`).
|
|
+- `IncomingRequests.drain()`, `.listenerRejected()`, `.refusing`; `OutgoingRequests.drain()`; `Session.drain()`'s two-error merge and `answering()`.
|
|
+- `idle-waiters.ts` (into `drain.ts`); `for (;;)` in `requestPastDrain`.
|
|
+
|
|
+## Tests
|
|
+
|
|
+`npm test`: lint and typecheck clean; 516 tests, 516 pass, 0 fail (baseline 515/515).
|
|
+
|
|
+Changed, all in `test/session-extras.test.ts`, all reaching into internals:
|
|
+
|
|
+- `incomingOn()` builds a `HeldMessages` (new `heldMessagesOn()` helper) instead of passing `sendPastDrain`; `standsInFor` imported from `bind-direction.ts`.
|
|
+- "drops a message whose link went while onRequest was still running": `link.drop()` → `link.transition('lost')`.
|
|
+- `LinkLife` describe: `drop/attach/open/stop/end` → `transition('lost'|'attached'|'bound'|'stopping'|'closed')`; the "names the event it warrants" test now asserts the effect lists, which is the same claim stated in the new vocabulary; one test added: a link bound after the shutdown began is ended, not brought up.
|
|
+- Held-message bounds: `offer()` returns the `Sms`, so `first.isHeld()`/`replaced.isHeld()` became "answering the replaced message frees nothing, answering the first frees it" (size before/after `sendResp()`), and `answered.release()` became `await answered.sendResp()`; `heldOn()` stubs `sendReturn` for that.
|
|
+
|
|
+No assertion was weakened; the `session.test.ts`, `readme.test.ts` and the shutdown/receipt integration tests run unchanged.
|
|
+
|
|
+## Not changed on purpose
|
|
+
|
|
+`ExpiringGroups` stays mechanism-only: the three owners' policies genuinely differ (refuse, evict oldest, evict with `spent` memory). `ReconnectLoop.halted` stays: `client()` runs a loop with no session behind it for `fromStart`. The public surface, `index.ts` and README are untouched.
|
|
diff --git a/docs/decisions.md b/docs/decisions.md
|
|
index c245097..f98e81e 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 the `dropLink` effect and the reconnect loop retries it on a fresh socket 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 `LinkLife`'s budget 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 `dropLink`
|
|
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 stay in `dropLink`: 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 at `dropLink` 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
|
|
@@ -757,7 +757,7 @@ rule and an index of the titles below.
|
|
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.
|
|
+ behind it for `fromStart`; a session's loop is stopped by the `stopReconnect` effect alone.
|
|
|
|
|
|
## Internals and tests
|
|
diff --git a/src/bind-direction.ts b/src/bind-direction.ts
|
|
new file mode 100644
|
|
index 0000000..ac251da
|
|
--- /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';
|
|
+
|
|
+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;
|
|
+}
|
|
+
|
|
+/** 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 };
|
|
+
|
|
+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..5251ad0
|
|
--- /dev/null
|
|
+++ b/src/drain.ts
|
|
@@ -0,0 +1,112 @@
|
|
+import type { SmppLog } from './log.ts';
|
|
+import type { VoidResult } from './result.ts';
|
|
+import { defaults } from './session-options.ts';
|
|
+
|
|
+/** Everything waiting for a count to fall to zero, and how such a wait is cut short. */
|
|
+export class IdleWaiters {
|
|
+ private readonly waiting: (() => void)[] = [];
|
|
+
|
|
+ /** Wakes everything waiting, whatever the count reads now. */
|
|
+ settle(): void {
|
|
+ for (const resolve of this.waiting.splice(0)) {
|
|
+ resolve();
|
|
+ }
|
|
+ }
|
|
+
|
|
+ /**
|
|
+ * 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(remaining: () => number, timeout: number, signal: AbortSignal | undefined): Promise<number> {
|
|
+ if (remaining() === 0) return Promise.resolve(0);
|
|
+
|
|
+ if (signal?.aborted === true) return Promise.resolve(remaining());
|
|
+
|
|
+ return new Promise<number>(resolve => {
|
|
+ let timer: NodeJS.Timeout | undefined = undefined;
|
|
+ const done = (): void => {
|
|
+ const index = this.waiting.indexOf(done);
|
|
+
|
|
+ if (timer) clearTimeout(timer);
|
|
+ if (index !== -1) this.waiting.splice(index, 1);
|
|
+
|
|
+ signal?.removeEventListener('abort', done);
|
|
+ resolve(remaining());
|
|
+ };
|
|
+
|
|
+ if (timeout > 0) {
|
|
+ timer = setTimeout(done, timeout);
|
|
+ timer.unref();
|
|
+ }
|
|
+
|
|
+ signal?.addEventListener('abort', done, { once: true });
|
|
+ this.waiting.push(done);
|
|
+ });
|
|
+ }
|
|
+}
|
|
+
|
|
+/** The two stores a shutdown waits on, and whether the link still carries what they hold. */
|
|
+export type Drainable = {
|
|
+ linkCarries: () => boolean;
|
|
+ /** Resolves 0 once the application has answered every message, or with how many it has not. */
|
|
+ messagesUnanswered: (timeout: number, signal: AbortSignal | undefined) => Promise<number>;
|
|
+ /** Resolves 0 once every request sent is answered, or with how many are not. */
|
|
+ requestsUnfinished: (timeout: number, signal: AbortSignal | undefined) => Promise<number>;
|
|
+};
|
|
+
|
|
+export type DrainOptions = {
|
|
+ log: SmppLog;
|
|
+ responseTimeout: number;
|
|
+ shutdownTimeout: number;
|
|
+ signal: AbortSignal | undefined;
|
|
+};
|
|
+
|
|
+/** 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 half may never wait forever: nothing else ends that wait. */
|
|
+function answeringBudget(options: DrainOptions): number {
|
|
+ if (options.shutdownTimeout > 0) return options.shutdownTimeout;
|
|
+
|
|
+ return options.responseTimeout > 0 ? options.responseTimeout : defaults.responseTimeout;
|
|
+}
|
|
+
|
|
+function report(log: SmppLog, unanswered: number, unfinished: number): VoidResult {
|
|
+ const lost: string[] = [];
|
|
+
|
|
+ if (unanswered > 0) {
|
|
+ log.warn('drain - shutting down with messages unanswered', { unanswered });
|
|
+ lost.push(`Shut down with ${String(unanswered)} message(s) unanswered`);
|
|
+ }
|
|
+
|
|
+ if (unfinished > 0) {
|
|
+ log.warn('drain - shutting down with requests unfinished', { unfinished });
|
|
+ lost.push(`Shut down with ${String(unfinished)} request(s) unfinished`);
|
|
+ }
|
|
+
|
|
+ return lost.length > 0 ? { err: new Error(lost.join('; ')) } : {};
|
|
+}
|
|
+
|
|
+/**
|
|
+ * Waits out the messages the application holds, then the requests on the wire. Answering a message
|
|
+ * can put a receipt on the wire; nothing on the wire produces a message, so that order covers both.
|
|
+ */
|
|
+export async function drain(stores: Drainable, options: DrainOptions): Promise<VoidResult> {
|
|
+ // No bound link, so nothing is on the wire to wait out.
|
|
+ if (!stores.linkCarries()) return {};
|
|
+
|
|
+ const deadline = options.shutdownTimeout > 0 ? Date.now() + options.shutdownTimeout : 0;
|
|
+ const unanswered = await stores.messagesUnanswered(answeringBudget(options), options.signal);
|
|
+
|
|
+ // One turn, so a receipt sent straight after the last answer is in the window before it is read.
|
|
+ await new Promise(resolve => { setImmediate(resolve); });
|
|
+
|
|
+ const unfinished = await stores.requestsUnfinished(leftOf(deadline), options.signal);
|
|
+
|
|
+ // The link went before the drain finished, so an empty window says nothing about the peer.
|
|
+ if (!stores.linkCarries()) return { err: new Error('The session closed before the drain finished') };
|
|
+
|
|
+ return report(options.log, unanswered, unfinished);
|
|
+}
|
|
diff --git a/src/error-from.ts b/src/error-from.ts
|
|
index 5d77236..e622a0a 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;
|
|
}
|
|
+
|
|
+/** A string in quotes, so an empty one and one of spaces are visible; anything else as namedValue(). */
|
|
+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..b8d004c 100644
|
|
--- a/src/held-messages.ts
|
|
+++ b/src/held-messages.ts
|
|
@@ -1,26 +1,29 @@
|
|
-import type { LinkLife } from './link-life.ts';
|
|
-import type { PduObject, PduObjectInput } from './pdu.ts';
|
|
-import type { Result } from './result.ts';
|
|
+import type { PduObject } from './pdu.ts';
|
|
import type { Session } from './session.ts';
|
|
-import type { SmsHandlers } from './sms.ts';
|
|
+import type { Sms, SmsHandlers } from './sms.ts';
|
|
import type { SmppLog } from './log.ts';
|
|
import { ExpiringGroups } from './expiring-groups.ts';
|
|
-import { IdleWaiters } from './idle-waiters.ts';
|
|
+import { IdleWaiters } from './drain.ts';
|
|
import { createSms } from './sms.ts';
|
|
import { retainedOctets } from './retained-pdu.ts';
|
|
|
|
export type HeldMessagesOptions = {
|
|
- link: LinkLife;
|
|
+ /** Changes with every link, so a message can tell the one it arrived on is gone. */
|
|
+ linkGeneration: () => number;
|
|
log: SmppLog;
|
|
max: number;
|
|
maxOctets: number;
|
|
/** Injected so expiry can be exercised without a wall clock. */
|
|
now?: (() => number) | undefined;
|
|
- sendPastDrain: SmsHandlers['send'];
|
|
+ /** How a receipt goes out: past a shutdown's refusal, since it answers a message the drain waits for. */
|
|
+ sendReceipt: SmsHandlers['send'];
|
|
session: Session;
|
|
timeout: number;
|
|
};
|
|
|
|
+/** A message handed to the application, and how many of its listeners are still working on it. */
|
|
+type Held = { key: string; pduObjs: PduObject[]; working: 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];
|
|
@@ -28,69 +31,19 @@ function keyOf(pduObjs: PduObject[]): string | undefined {
|
|
return first ? String(first.seqNr) : undefined;
|
|
}
|
|
|
|
-type HoldRoute = Pick<HeldMessagesOptions, 'link' | 'sendPastDrain' | 'session'>;
|
|
-
|
|
/**
|
|
- * 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.
|
|
+ * The messages handed to the application that it has not answered yet, which is what a shutdown
|
|
+ * waits on. A hold ends the first of six ways, numbered below: 1 answered, 2 the last listener
|
|
+ * working on it rejecting, 3 no listener taking it or one throwing, 4 a later message on its
|
|
+ * sequence number, 5 its deadline, 6 the link going.
|
|
*/
|
|
-export class MessageHold implements SmsHandlers {
|
|
- private readonly generation: number;
|
|
- private readonly heldMessages: 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;
|
|
- 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);
|
|
- }
|
|
-
|
|
- /** A turn later, so a `sendDlr()` called straight after `sendResp()` still goes out past a drain. */
|
|
- answered(): void {
|
|
- setImmediate(() => { this.release(); });
|
|
- }
|
|
-
|
|
- lostLink(): boolean {
|
|
- return this.route.link.generation() !== this.generation;
|
|
- }
|
|
-
|
|
- /** A rejection leaves the other listeners running, so only the last one to fail gives the message up. */
|
|
- listenerGaveUp(): void {
|
|
- this.working--;
|
|
-
|
|
- 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);
|
|
- }
|
|
-
|
|
- /** A receipt for a message still held is what a drain waits for, so it goes out past the drain. */
|
|
- send(input: PduObjectInput): Promise<Result<{ pduObj: PduObject }>> {
|
|
- return this.isHeld() ? this.route.sendPastDrain(input) : this.route.session.send(input);
|
|
- }
|
|
-}
|
|
-
|
|
-/** The messages handed to the application that it has not answered yet, held by their segments. */
|
|
export class HeldMessages {
|
|
- private readonly held: ExpiringGroups<PduObject[]>;
|
|
+ private readonly held: ExpiringGroups<Held>;
|
|
private readonly idleWaiters = new IdleWaiters();
|
|
- private readonly log: SmppLog;
|
|
- 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<object, MessageHold>();
|
|
- private readonly route: HoldRoute;
|
|
+ private readonly offered = new WeakMap<object, Held>();
|
|
+ private readonly options: HeldMessagesOptions;
|
|
+ private refusing = false;
|
|
|
|
constructor(options: HeldMessagesOptions) {
|
|
this.held = new ExpiringGroups({
|
|
@@ -99,9 +52,7 @@ export class HeldMessages {
|
|
onSweep: () => { this.sweep(); },
|
|
timeout: options.timeout,
|
|
});
|
|
- this.log = options.log;
|
|
- this.maxOctets = options.maxOctets;
|
|
- this.route = { link: options.link, sendPastDrain: options.sendPastDrain, session: options.session };
|
|
+ this.options = options;
|
|
}
|
|
|
|
get octetsHeld(): number {
|
|
@@ -112,69 +63,85 @@ export class HeldMessages {
|
|
return this.held.size;
|
|
}
|
|
|
|
- /** Whether a message arriving now is past the bound, once the expired are swept. */
|
|
+ /** Whether a message arriving now is past the bound. Logs once on reaching it, once on coming back to half. */
|
|
full(): boolean {
|
|
this.sweep();
|
|
|
|
- return this.held.full || this.held.weight >= this.maxOctets;
|
|
- }
|
|
-
|
|
- private hold(key: string, pduObjs: PduObject[], listeners: number): MessageHold {
|
|
- const hold = new MessageHold(this, this.route, pduObjs, listeners);
|
|
+ const { log, max, maxOctets } = this.options;
|
|
|
|
- this.sweep();
|
|
+ if (this.held.full || this.held.weight >= maxOctets) {
|
|
+ if (!this.refusing) {
|
|
+ this.refusing = true;
|
|
+ log.warn('heldMessages - unanswered messages at their bound, refusing new ones until the application answers', {
|
|
+ messages: this.size,
|
|
+ octets: this.octetsHeld,
|
|
+ });
|
|
+ }
|
|
|
|
- if (this.held.get(key)) {
|
|
- this.log.warn('heldMessages - replacing a message on a re-used sequence number', { seqNr: Number(key) });
|
|
+ return true;
|
|
}
|
|
|
|
- this.held.set(key, pduObjs);
|
|
- this.held.weigh(key, pduObjs.reduce((sum, pduObj) => sum + retainedOctets(pduObj), 0));
|
|
+ // Half, so a peer keeping its window full does not flip this on every answer.
|
|
+ if (this.refusing && this.size <= max / 2 && this.octetsHeld <= maxOctets / 2) {
|
|
+ this.refusing = false;
|
|
+ log.info('heldMessages - unanswered messages down to half their bound, accepting again', { messages: this.size });
|
|
+ }
|
|
|
|
- return hold;
|
|
+ return false;
|
|
}
|
|
|
|
- offer(pduObjs: PduObject[], answeredAs?: string): MessageHold | undefined {
|
|
+ /** Hands the message to the application as an `sms` event and holds it until one of the six ways out. */
|
|
+ offer(pduObjs: PduObject[], answeredAs?: string): Sms | undefined {
|
|
const key = keyOf(pduObjs);
|
|
|
|
if (key === undefined) return undefined;
|
|
|
|
- const hold = this.hold(key, pduObjs, this.route.session.listenerCount('sms'));
|
|
- const sms = createSms({ answeredAs, pduObjs, session: this.route.session }, hold);
|
|
+ const { linkGeneration, sendReceipt, session } = this.options;
|
|
+ const generation = linkGeneration();
|
|
+ const held = this.keep(key, pduObjs);
|
|
+ const sms = createSms({ answeredAs, pduObjs, session }, {
|
|
+ answered: () => { this.release(held); },
|
|
+ lostLink: () => linkGeneration() !== generation,
|
|
+ send: sendReceipt,
|
|
+ });
|
|
|
|
- this.offered.set(sms, hold);
|
|
+ this.offered.set(sms, held);
|
|
|
|
- if (!this.route.session.emit('sms', sms)) hold.release();
|
|
+ // 3: not work a shutdown can wait for.
|
|
+ if (!session.emit('sms', sms)) this.release(held);
|
|
|
|
- return hold;
|
|
+ return sms;
|
|
}
|
|
|
|
- /** One listener gave up on a message; the last one to do so is what releases it. */
|
|
+ /** 2: one listener gave up on a message; the last one to do so is what releases it. */
|
|
listenerRejected(message: unknown): void {
|
|
if (typeof message !== 'object' || message === null) return;
|
|
|
|
- this.offered.get(message)?.listenerGaveUp();
|
|
- }
|
|
+ const held = this.offered.get(message);
|
|
|
|
- holds(pduObjs: PduObject[]): boolean {
|
|
- const key = keyOf(pduObjs);
|
|
+ if (!held) return;
|
|
|
|
- return key !== undefined && this.held.get(key) === pduObjs;
|
|
+ held.working--;
|
|
+
|
|
+ if (held.working <= 0) this.release(held);
|
|
}
|
|
|
|
- release(pduObjs: PduObject[]): void {
|
|
- const key = keyOf(pduObjs);
|
|
+ /** 5: drops every message past its deadline. Runs before each keep and on its own timer. */
|
|
+ sweep(): void {
|
|
+ const expired = this.held.takeExpired();
|
|
|
|
- // Identity, not the key: a wrapped sequence number must not release someone else's message.
|
|
- if (key === undefined || this.held.get(key) !== pduObjs) return;
|
|
+ if (expired.length === 0) return;
|
|
|
|
- this.held.delete(key);
|
|
+ this.options.log.warn('heldMessages - messages the application never answered', {
|
|
+ messages: expired.length,
|
|
+ });
|
|
this.settle();
|
|
}
|
|
|
|
- /** Drops every message: their segments went with the link, so no answer of ours correlates now. */
|
|
+ /** 6: drops every message. Their segments went with the link, so no answer of ours correlates now. */
|
|
clear(): void {
|
|
this.held.takeAll();
|
|
+ this.refusing = false;
|
|
this.idleWaiters.settle();
|
|
}
|
|
|
|
@@ -183,15 +150,28 @@ export class HeldMessages {
|
|
return this.idleWaiters.wait(() => this.held.size, timeout, signal);
|
|
}
|
|
|
|
- /** Drops every message past its deadline. Runs before each hold and on its own timer. */
|
|
- sweep(): void {
|
|
- const expired = this.held.takeExpired();
|
|
+ /** 4 is in here: a re-used sequence number replaces the message held on it. */
|
|
+ private keep(key: string, pduObjs: PduObject[]): Held {
|
|
+ this.sweep();
|
|
|
|
- if (expired.length === 0) return;
|
|
+ if (this.held.get(key)) {
|
|
+ this.options.log.warn('heldMessages - replacing a message on a re-used sequence number', { seqNr: Number(key) });
|
|
+ }
|
|
|
|
- this.log.warn('heldMessages - messages the application never answered', {
|
|
- messages: expired.length,
|
|
- });
|
|
+ const held: Held = { key, pduObjs, working: this.options.session.listenerCount('sms') };
|
|
+
|
|
+ this.held.set(key, held);
|
|
+ this.held.weigh(key, pduObjs.reduce((sum, pduObj) => sum + retainedOctets(pduObj), 0));
|
|
+
|
|
+ return held;
|
|
+ }
|
|
+
|
|
+ /** 1 comes through here, as do 2 and 3. */
|
|
+ private release(held: Held): void {
|
|
+ // Identity, not the key: a wrapped sequence number must not release someone else's message.
|
|
+ if (this.held.get(held.key) !== held) return;
|
|
+
|
|
+ this.held.delete(held.key);
|
|
this.settle();
|
|
}
|
|
|
|
diff --git a/src/idle-waiters.ts b/src/idle-waiters.ts
|
|
deleted file mode 100644
|
|
index dd29a9e..0000000
|
|
--- a/src/idle-waiters.ts
|
|
+++ /dev/null
|
|
@@ -1,47 +0,0 @@
|
|
-/** What is left of a budget, in the shape a wait takes it: 0 waits forever. */
|
|
-export function leftOf(deadline: number): number {
|
|
- return deadline === 0 ? 0 : Math.max(1, deadline - Date.now());
|
|
-}
|
|
-
|
|
-/** Everything waiting for a count to fall to zero, and how such a wait is cut short. */
|
|
-export class IdleWaiters {
|
|
- private readonly waiting: (() => void)[] = [];
|
|
-
|
|
- /** Wakes everything waiting, whatever the count reads now. */
|
|
- settle(): void {
|
|
- for (const resolve of this.waiting.splice(0)) {
|
|
- resolve();
|
|
- }
|
|
- }
|
|
-
|
|
- /**
|
|
- * 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(remaining: () => number, timeout: number, signal: AbortSignal | undefined): Promise<number> {
|
|
- if (remaining() === 0) return Promise.resolve(0);
|
|
-
|
|
- if (signal?.aborted === true) return Promise.resolve(remaining());
|
|
-
|
|
- return new Promise<number>(resolve => {
|
|
- let timer: NodeJS.Timeout | undefined = undefined;
|
|
- const done = (): void => {
|
|
- const index = this.waiting.indexOf(done);
|
|
-
|
|
- if (timer) clearTimeout(timer);
|
|
- if (index !== -1) this.waiting.splice(index, 1);
|
|
-
|
|
- signal?.removeEventListener('abort', done);
|
|
- resolve(remaining());
|
|
- };
|
|
-
|
|
- if (timeout > 0) {
|
|
- timer = setTimeout(done, timeout);
|
|
- timer.unref();
|
|
- }
|
|
-
|
|
- signal?.addEventListener('abort', done, { once: true });
|
|
- this.waiting.push(done);
|
|
- });
|
|
- }
|
|
-}
|
|
diff --git a/src/incoming-requests.ts b/src/incoming-requests.ts
|
|
index 51aeec7..2c13e49 100644
|
|
--- a/src/incoming-requests.ts
|
|
+++ b/src/incoming-requests.ts
|
|
@@ -1,18 +1,17 @@
|
|
import type { Concat } from './concat.ts';
|
|
import type { DlrMerger } from './dlr-merger.ts';
|
|
import type { ErrorName } from './defs/errors.ts';
|
|
-import type { HeldMessagesOptions } from './held-messages.ts';
|
|
+import type { HeldMessages } from './held-messages.ts';
|
|
import type { LinkLife } from './link-life.ts';
|
|
import type { LostGroup, Refusal } from './reassembly.ts';
|
|
import type { OnRequest } from './session-options.ts';
|
|
import type { PduObject } from './pdu.ts';
|
|
-import type { VoidResult } from './result.ts';
|
|
import type { Session } from './session.ts';
|
|
import type { SmppLog } from './log.ts';
|
|
import type { SmsIdFormat } from './sms-id.ts';
|
|
-import { HeldMessages } from './held-messages.ts';
|
|
import { Reassembler } from './reassembly.ts';
|
|
-import { bindCommands, defaults, standsInFor } from './session-options.ts';
|
|
+import { bindCommands, standsInFor } from './bind-direction.ts';
|
|
+import { defaults } from './session-options.ts';
|
|
import { concatOf } from './concat.ts';
|
|
import { detach } from './retained-pdu.ts';
|
|
import { dlrFromPdu } from './dlr.ts';
|
|
@@ -45,13 +44,13 @@ const lostReasons: Record<LostGroup['reason'], string> = {
|
|
|
|
export type IncomingRequestsOptions = {
|
|
dlrMerger: DlrMerger;
|
|
+ held: HeldMessages;
|
|
link: LinkLife;
|
|
log: SmppLog;
|
|
maxOctets?: number | undefined;
|
|
maxReassembly?: number | undefined;
|
|
onRequest?: OnRequest | undefined;
|
|
reassemblyTimeout?: number | undefined;
|
|
- sendPastDrain: HeldMessagesOptions['sendPastDrain'];
|
|
session: Session;
|
|
smsIdFormat?: SmsIdFormat | undefined;
|
|
systemId?: string | undefined;
|
|
@@ -68,19 +67,10 @@ export class IncomingRequests {
|
|
private readonly session: Session;
|
|
private readonly smsIdFormat: SmsIdFormat;
|
|
private readonly systemId: string;
|
|
- private refusing = false;
|
|
|
|
constructor(options: IncomingRequestsOptions) {
|
|
this.dlrMerger = options.dlrMerger;
|
|
- this.held = new HeldMessages({
|
|
- link: options.link,
|
|
- log: options.log,
|
|
- max: defaults.maxHeldMessages,
|
|
- maxOctets: defaults.maxHeldOctets,
|
|
- sendPastDrain: options.sendPastDrain,
|
|
- session: options.session,
|
|
- timeout: defaults.heldMessageTimeout,
|
|
- });
|
|
+ this.held = options.held;
|
|
this.link = options.link;
|
|
this.log = options.log;
|
|
this.onRequest = options.onRequest;
|
|
@@ -148,28 +138,11 @@ export class IncomingRequests {
|
|
}
|
|
}
|
|
|
|
- /** Drops the segments of every message that never became whole, and of every one still held. */
|
|
+ /** Drops the segments of every message that never became whole. */
|
|
clear(): void {
|
|
- this.refusing = false;
|
|
- this.held.clear();
|
|
this.reassembler.clear();
|
|
}
|
|
|
|
- listenerRejected(sms: unknown): void {
|
|
- this.held.listenerRejected(sms);
|
|
- }
|
|
-
|
|
- /** Waits out the messages the application still holds, and says how many it never answered. */
|
|
- async drain(timeout: number, signal: AbortSignal | undefined): Promise<VoidResult> {
|
|
- const unanswered = await this.held.idle(timeout, signal);
|
|
-
|
|
- if (unanswered === 0) return {};
|
|
-
|
|
- this.log.warn('session - shutting down with messages unanswered', { timeout, unanswered });
|
|
-
|
|
- return { err: new Error(`Shut down with ${String(unanswered)} message(s) unanswered`) };
|
|
- }
|
|
-
|
|
private async unhandled(pduObj: PduObject): Promise<void> {
|
|
if (bindCommands.includes(pduObj.cmdName)) {
|
|
this.log.info('session - bind on an already bound session', { cmdName: pduObj.cmdName });
|
|
@@ -212,35 +185,15 @@ export class IncomingRequests {
|
|
}
|
|
|
|
private async refusedAtBound(pduObj: PduObject): Promise<boolean> {
|
|
- if (this.held.full()) {
|
|
- if (!this.refusing) {
|
|
- this.refusing = true;
|
|
- this.log.warn('session - unanswered messages at their bound, refusing new ones until the application answers', {
|
|
- messages: this.held.size,
|
|
- octets: this.held.octetsHeld,
|
|
- });
|
|
- }
|
|
-
|
|
- this.log.verbose('session - unanswered messages at their bound, asking the peer to retry', {
|
|
- cmdName: pduObj.cmdName,
|
|
- seqNr: pduObj.seqNr,
|
|
- });
|
|
- await this.session.sendReturn(pduObj, throttledStatus(this.carriedAs(pduObj)));
|
|
+ if (!this.held.full()) return false;
|
|
|
|
- return true;
|
|
- }
|
|
-
|
|
- // Half, so a peer keeping its window full does not flip this on every answer.
|
|
- if (
|
|
- this.refusing
|
|
- && this.held.size <= defaults.maxHeldMessages / 2
|
|
- && this.held.octetsHeld <= defaults.maxHeldOctets / 2
|
|
- ) {
|
|
- this.refusing = false;
|
|
- this.log.info('session - unanswered messages down to half their bound, accepting again', { messages: this.held.size });
|
|
- }
|
|
+ this.log.verbose('session - unanswered messages at their bound, asking the peer to retry', {
|
|
+ cmdName: pduObj.cmdName,
|
|
+ seqNr: pduObj.seqNr,
|
|
+ });
|
|
+ await this.session.sendReturn(pduObj, throttledStatus(this.carriedAs(pduObj)));
|
|
|
|
- return false;
|
|
+ return true;
|
|
}
|
|
|
|
/**
|
|
diff --git a/src/link-life.ts b/src/link-life.ts
|
|
index f44f2ea..d2927b0 100644
|
|
--- a/src/link-life.ts
|
|
+++ b/src/link-life.ts
|
|
@@ -4,14 +4,33 @@ import type { VoidResult } from './result.ts';
|
|
export type LinkLifeOptions = {
|
|
log: SmppLog;
|
|
now?: (() => number) | undefined;
|
|
- /** Whether a dropped link is followed by another one until stop(). */
|
|
+ /** Whether a lost link is followed by another one, until the session stops. */
|
|
reconnects: boolean;
|
|
/** How long a request may wait for a link. 0 waits for as long as one may still arrive. */
|
|
timeout: number;
|
|
};
|
|
|
|
-/** `binding`: a socket is attached and its bind is not answered yet, so it carries nothing but that bind. */
|
|
-type Phase = 'binding' | 'down' | 'ended' | 'up';
|
|
+/**
|
|
+ * `up`: a bound socket carries requests. `binding`: a socket is attached and only its bind may go
|
|
+ * out. `down`: no socket, and the reconnect loop owes one. `closed`: over, and nothing brings it back.
|
|
+ */
|
|
+export type LinkPhase = 'binding' | 'closed' | 'down' | 'up';
|
|
+
|
|
+/**
|
|
+ * `attached`: a socket from the reconnect loop. `bound`: its bind was answered. `lost`: the socket
|
|
+ * went, whoever noticed. `stopping`: a shutdown began, so no link follows this one. `closed`: the
|
|
+ * shutdown is over.
|
|
+ */
|
|
+export type LinkEvent = 'attached' | 'bound' | 'closed' | 'lost' | 'stopping';
|
|
+
|
|
+/** What the session does after a transition, in the order returned. */
|
|
+export type LinkEffect =
|
|
+ | 'dropLink'
|
|
+ | 'emitClose'
|
|
+ | 'emitDisconnected'
|
|
+ | 'linkUp'
|
|
+ | 'scheduleReconnect'
|
|
+ | 'stopReconnect';
|
|
|
|
type Waiter = (result: VoidResult) => void;
|
|
|
|
@@ -27,7 +46,10 @@ function over(): Error {
|
|
return new Error('Session is closed');
|
|
}
|
|
|
|
-/** Whether the session's link lives, and where a request with no link to go out on waits for the next one. */
|
|
+/**
|
|
+ * The link's life as one state machine: `transition()` is the whole table, and every predicate
|
|
+ * below reads the phase it keeps. Also where a request with no link waits for the next one.
|
|
+ */
|
|
export class LinkLife {
|
|
private readonly log: SmppLog;
|
|
private readonly now: () => number;
|
|
@@ -35,8 +57,8 @@ export class LinkLife {
|
|
private readonly timeout: number;
|
|
private readonly waiting = new Set<Waiter>();
|
|
private drops = 0;
|
|
- private phase: Phase = 'up';
|
|
- private stopped = false;
|
|
+ private linkPhase: LinkPhase = 'up';
|
|
+ private stopping = false;
|
|
|
|
constructor(options: LinkLifeOptions) {
|
|
this.log = options.log;
|
|
@@ -45,33 +67,49 @@ export class LinkLife {
|
|
this.timeout = options.timeout;
|
|
}
|
|
|
|
- /** A socket is on the link, bound or not. */
|
|
- isAttached(): boolean {
|
|
- return this.phase === 'binding' || this.phase === 'up';
|
|
+ get phase(): LinkPhase {
|
|
+ return this.linkPhase;
|
|
}
|
|
|
|
- /** Whether a request can go out right now. */
|
|
- isUp(): boolean {
|
|
- return this.phase === 'up';
|
|
+ transition(event: LinkEvent): LinkEffect[] {
|
|
+ switch (event) {
|
|
+ case 'attached':
|
|
+ if (this.linkPhase === 'down') this.linkPhase = 'binding';
|
|
+
|
|
+ return [];
|
|
+ case 'bound':
|
|
+ if (this.linkPhase !== 'binding') return [];
|
|
+
|
|
+ return this.stopping ? this.lose() : this.open();
|
|
+ case 'closed':
|
|
+ return this.end();
|
|
+ case 'lost':
|
|
+ return this.lose();
|
|
+ case 'stopping':
|
|
+ this.stopping = true;
|
|
+
|
|
+ return ['stopReconnect'];
|
|
+ }
|
|
}
|
|
|
|
- private isOver(): boolean {
|
|
- return this.phase === 'ended';
|
|
+ /** A socket is on the link, bound or not. */
|
|
+ attached(): boolean {
|
|
+ return this.linkPhase === 'binding' || this.linkPhase === 'up';
|
|
}
|
|
|
|
- /** The session is shutting down: nothing new is taken, and no link follows this one. */
|
|
- isStopped(): boolean {
|
|
- return this.stopped;
|
|
+ /** Whether a request can go out right now. */
|
|
+ carries(): boolean {
|
|
+ return this.linkPhase === 'up';
|
|
}
|
|
|
|
- /** Whether a link that drops now is followed by another. */
|
|
- retrying(): boolean {
|
|
- return this.reconnects && !this.stopped;
|
|
+ /** A shutdown began: nothing new is taken. */
|
|
+ isStopping(): boolean {
|
|
+ return this.stopping;
|
|
}
|
|
|
|
- /** Not up and not over, with a link to come. */
|
|
+ /** Not carrying, with a link still to come. */
|
|
awaitsNextLink(): boolean {
|
|
- return !this.isUp() && !this.isOver() && this.retrying();
|
|
+ return (this.linkPhase === 'down' || this.linkPhase === 'binding') && this.retrying();
|
|
}
|
|
|
|
/** Changes with every drop, so what was read off one link can tell that link is gone. */
|
|
@@ -81,62 +119,60 @@ export class LinkLife {
|
|
|
|
/** Why no request will ever be admitted, or undefined while one may still get through. */
|
|
refusal(): Error | undefined {
|
|
- return this.isUp() || this.awaitsNextLink() ? undefined : over();
|
|
+ return this.carries() || this.awaitsNextLink() ? undefined : over();
|
|
}
|
|
|
|
/** One budget for a request, however many links it waits through. */
|
|
- hold(signal: AbortSignal | undefined): () => Promise<VoidResult> {
|
|
+ budget(signal: AbortSignal | undefined): () => Promise<VoidResult> {
|
|
const deadline = this.timeout > 0 ? this.now() + this.timeout : 0;
|
|
|
|
return () => this.wait(deadline, signal);
|
|
}
|
|
|
|
- /** A socket from the reconnect loop, not yet bound. An ended session stays ended. */
|
|
- attach(): void {
|
|
- if (this.isOver()) return;
|
|
-
|
|
- this.phase = 'binding';
|
|
+ private retrying(): boolean {
|
|
+ return this.reconnects && !this.stopping;
|
|
}
|
|
|
|
- /** The link is bound: everything held goes out on it. */
|
|
- open(): void {
|
|
- this.phase = 'up';
|
|
+ private open(): LinkEffect[] {
|
|
+ this.linkPhase = 'up';
|
|
|
|
if (this.waiting.size > 0) {
|
|
this.log.verbose('linkLife - sending what was held for a link', { held: this.waiting.size });
|
|
}
|
|
|
|
this.release({});
|
|
+
|
|
+ return ['linkUp'];
|
|
}
|
|
|
|
- /** The attached link is gone: the event that says so, or undefined when there was none to lose. */
|
|
- drop(): 'close' | 'disconnected' | undefined {
|
|
- if (!this.isAttached()) return undefined;
|
|
+ private lose(): LinkEffect[] {
|
|
+ if (!this.attached()) return [];
|
|
|
|
- this.phase = 'down';
|
|
this.drops++;
|
|
+ this.linkPhase = 'down';
|
|
|
|
- return this.retrying() ? 'disconnected' : 'close';
|
|
- }
|
|
+ if (!this.retrying()) return ['dropLink', ...this.end()];
|
|
|
|
- stop(): void {
|
|
- this.stopped = true;
|
|
+ return ['dropLink', 'emitDisconnected', 'scheduleReconnect'];
|
|
}
|
|
|
|
- /** The session is over: nothing held will ever go out. False means it already was. */
|
|
- end(): boolean {
|
|
- if (this.isOver()) return false;
|
|
+ private end(): LinkEffect[] {
|
|
+ if (this.linkPhase === 'closed') return [];
|
|
+
|
|
+ const dropped = this.attached();
|
|
+
|
|
+ if (dropped) this.drops++;
|
|
|
|
- this.phase = 'ended';
|
|
- this.stopped = true;
|
|
+ this.linkPhase = 'closed';
|
|
+ this.stopping = true;
|
|
this.release({ err: over() });
|
|
|
|
- return true;
|
|
+ return dropped ? ['dropLink', 'emitClose'] : ['emitClose'];
|
|
}
|
|
|
|
/** Resolves once a link can carry the request, or with the reason none ever will. */
|
|
private wait(deadline: number, signal: AbortSignal | undefined): Promise<VoidResult> {
|
|
- if (this.isUp()) return Promise.resolve({});
|
|
+ if (this.carries()) return Promise.resolve({});
|
|
|
|
const refused = this.refusal();
|
|
|
|
diff --git a/src/outgoing-requests.ts b/src/outgoing-requests.ts
|
|
index a0adf24..8c5571e 100644
|
|
--- a/src/outgoing-requests.ts
|
|
+++ b/src/outgoing-requests.ts
|
|
@@ -7,7 +7,7 @@ import type { SmppLog } from './log.ts';
|
|
import { PendingRequests } from './pending-requests.ts';
|
|
import { SendWindow } from './send-window.ts';
|
|
import { UnansweredError } from './unanswered-error.ts';
|
|
-import { bindCommands } from './session-options.ts';
|
|
+import { bindCommands } from './bind-direction.ts';
|
|
import { objToPdu } from './pdu.ts';
|
|
|
|
export type OutgoingRequestsOptions = {
|
|
@@ -35,7 +35,6 @@ function misuse(input: PduObjectInput): Error | undefined {
|
|
/** Everything this end asks of the peer: which link carries it, how many at once, and the answer. */
|
|
export class OutgoingRequests {
|
|
private readonly link: LinkLife;
|
|
- private readonly log: SmppLog;
|
|
private readonly pending: PendingRequests;
|
|
private readonly responseTimeout: number;
|
|
private readonly transport: PduTransport;
|
|
@@ -43,7 +42,6 @@ export class OutgoingRequests {
|
|
|
|
constructor(options: OutgoingRequestsOptions) {
|
|
this.link = options.link;
|
|
- this.log = options.log;
|
|
this.pending = new PendingRequests(options.log);
|
|
this.responseTimeout = options.responseTimeout;
|
|
this.transport = options.transport;
|
|
@@ -51,7 +49,7 @@ export class OutgoingRequests {
|
|
}
|
|
|
|
canCarry(): boolean {
|
|
- return this.link.isUp() && !this.transport.sock.destroyed;
|
|
+ return this.link.carries() && !this.transport.sock.destroyed;
|
|
}
|
|
|
|
/** The link is gone, and every answer still owed on it with it. */
|
|
@@ -76,44 +74,27 @@ export class OutgoingRequests {
|
|
if (wrong) return Promise.resolve({ err: wrong });
|
|
|
|
// With no link, the request is refused as closed further on.
|
|
- if (this.link.isStopped() && this.canCarry()) {
|
|
+ if (this.link.isStopping() && this.canCarry()) {
|
|
return Promise.resolve({ err: new Error('Session is shutting down') });
|
|
}
|
|
|
|
return this.requestPastDrain(input, options);
|
|
}
|
|
|
|
- /** request() without the drain's refusal, which a receipt for a held message has to take. */
|
|
- async requestPastDrain(
|
|
- input: PduObjectInput,
|
|
- options: SendOptions,
|
|
- ): Promise<Result<{ pduObj: PduObject }>> {
|
|
+ /** request() without the drain's refusal, which a receipt for a message the drain waits on has to take. */
|
|
+ requestPastDrain(input: PduObjectInput, options: SendOptions): Promise<Result<{ pduObj: PduObject }>> {
|
|
const refused = this.refuse(input, options);
|
|
|
|
- if (refused) return { err: refused };
|
|
+ if (refused) return Promise.resolve({ err: refused });
|
|
|
|
// A bind is what makes a link usable, so it cannot wait for one: it takes the link's answer now.
|
|
if (bindCommands.includes(input.cmdName)) {
|
|
const shut = this.link.refusal();
|
|
|
|
- return shut ? { err: shut } : this.requestOnCurrentLink(input, options);
|
|
+ return shut ? Promise.resolve({ err: shut }) : this.requestOnCurrentLink(input, options);
|
|
}
|
|
|
|
- const waitForLink = this.link.hold(options.signal);
|
|
-
|
|
- for (;;) {
|
|
- const held = await waitForLink();
|
|
-
|
|
- if (held.err) return { err: held.err };
|
|
-
|
|
- const slot = await this.window.acquire(options.signal);
|
|
-
|
|
- if (slot.err) return { err: slot.err };
|
|
-
|
|
- const attempt = await this.attempt(input, options).finally(() => { this.window.release(); });
|
|
-
|
|
- if (!this.retriesOnNextLink(attempt)) return attempt.result;
|
|
- }
|
|
+ return this.carry(input, options, this.link.budget(options.signal));
|
|
}
|
|
|
|
/** Straight onto the current link, for what has to go out either way. */
|
|
@@ -124,21 +105,31 @@ export class OutgoingRequests {
|
|
return (await this.attempt(input, options)).result;
|
|
}
|
|
|
|
- /** Waits out the requests already on the wire, and says how many never finished. */
|
|
- async drain(timeout: number, signal: AbortSignal | undefined): Promise<VoidResult> {
|
|
- const unfinished = await this.window.idle(timeout, signal);
|
|
+ /** Resolves 0 once every request sent is answered, or with how many are not: on the wire, or queued behind the window. */
|
|
+ idle(timeout: number, signal: AbortSignal | undefined): Promise<number> {
|
|
+ return this.window.idle(timeout, signal);
|
|
+ }
|
|
|
|
- if (unfinished === 0) return {};
|
|
+ /** On the first link the budget admits; one the socket refused untouched waits for the next link and goes again. */
|
|
+ private async carry(
|
|
+ input: PduObjectInput,
|
|
+ options: SendOptions,
|
|
+ waitForLink: () => Promise<VoidResult>,
|
|
+ ): Promise<Result<{ pduObj: PduObject }>> {
|
|
+ const held = await waitForLink();
|
|
|
|
- this.log.warn('outgoingRequests - shutting down with requests unfinished', { timeout, unfinished });
|
|
+ if (held.err) return { err: held.err };
|
|
|
|
- return { err: new Error(`Shut down with ${String(unfinished)} request(s) unfinished`) };
|
|
- }
|
|
+ const slot = await this.window.acquire(options.signal);
|
|
+
|
|
+ if (slot.err) return { err: slot.err };
|
|
+
|
|
+ const attempt = await this.attempt(input, options).finally(() => { this.window.release(); });
|
|
+
|
|
+ // Only once the link is dropped: until then the retry lands straight back on the dead socket.
|
|
+ if (attempt.retryOnNextLink && this.link.awaitsNextLink()) return this.carry(input, options, waitForLink);
|
|
|
|
- /** Nothing reached the socket, so the next link carries it. */
|
|
- private retriesOnNextLink(attempt: Attempt): boolean {
|
|
- // Until the link is dropped it admits the retry straight back onto the dead socket, and the loop spins.
|
|
- return attempt.retryOnNextLink && this.link.awaitsNextLink();
|
|
+ return attempt.result;
|
|
}
|
|
|
|
/** Why a request cannot go out at all, as opposed to not yet. */
|
|
diff --git a/src/send-window.ts b/src/send-window.ts
|
|
index e13d67d..50294cb 100644
|
|
--- a/src/send-window.ts
|
|
+++ b/src/send-window.ts
|
|
@@ -1,6 +1,6 @@
|
|
import type { SmppLog } from './log.ts';
|
|
import type { VoidResult } from './result.ts';
|
|
-import { IdleWaiters } from './idle-waiters.ts';
|
|
+import { IdleWaiters } from './drain.ts';
|
|
|
|
export type SendWindowOptions = {
|
|
limit: number;
|
|
diff --git a/src/server.ts b/src/server.ts
|
|
index 2b9aefe..bcdf532 100644
|
|
--- a/src/server.ts
|
|
+++ b/src/server.ts
|
|
@@ -1,4 +1,5 @@
|
|
-import type { BindType, CloseOptions, OnRequest } from './session-options.ts';
|
|
+import type { BindType } from './bind-direction.ts';
|
|
+import type { CloseOptions, OnRequest } from './session-options.ts';
|
|
import type { PduObject, TlvInputs } from './pdu.ts';
|
|
import type { Result, VoidResult } from './result.ts';
|
|
import type { Server as NetServer, Socket } from 'node:net';
|
|
@@ -6,7 +7,8 @@ import type { Server as TlsServer, TlsOptions } from 'node:tls';
|
|
import type { SmppLog } from './log.ts';
|
|
import { EventEmitter } from 'node:events';
|
|
import { Session, defaultSystemId } from './session.ts';
|
|
-import { bindTypeFromCommand, checkSessionOptions } from './session-options.ts';
|
|
+import { bindTypeFromCommand } from './bind-direction.ts';
|
|
+import { checkSessionOptions } from './session-options.ts';
|
|
import { createServer as createNetServer } from 'node:net';
|
|
import { createServer as createTlsServer } from 'node:tls';
|
|
import { defaultInterfaceVersion } from './defs/constants.ts';
|
|
diff --git a/src/session-options.ts b/src/session-options.ts
|
|
index 0b768c9..93ae835 100644
|
|
--- a/src/session-options.ts
|
|
+++ b/src/session-options.ts
|
|
@@ -11,7 +11,7 @@ import type { Socket } from 'node:net';
|
|
import { backoffDefaults } from './reconnect-loop.ts';
|
|
import { defaultMaxOctets } from './reassembly.ts';
|
|
import { isSmsIdNotation, smsIdNotations, smsIdPlaces } from './sms-id.ts';
|
|
-import { namedValue } from './error-from.ts';
|
|
+import { namedValue, quoted } from './error-from.ts';
|
|
|
|
export type SessionEvents = {
|
|
close: [];
|
|
@@ -26,53 +26,6 @@ export type SessionEvents = {
|
|
sms: [Sms];
|
|
};
|
|
|
|
-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';
|
|
-
|
|
-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;
|
|
-}
|
|
-
|
|
export type SendOptions = { signal?: AbortSignal | undefined };
|
|
|
|
/** An already-aborted signal skips the drain; one that fires during it cuts the wait short. */
|
|
@@ -118,34 +71,6 @@ export type SessionOptions = {
|
|
|
|
export const defaultSystemId = '';
|
|
|
|
-/** 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 };
|
|
-
|
|
-function quoted(value: unknown): string {
|
|
- return typeof value === 'string' ? JSON.stringify(value) : namedValue(value);
|
|
-}
|
|
-
|
|
-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 } };
|
|
-}
|
|
-
|
|
export const defaults = {
|
|
/** Receipts of a multipart message can be a working day apart, so the cap does the bounding. */
|
|
dlrMergeTimeout: 86_400_000,
|
|
diff --git a/src/session.ts b/src/session.ts
|
|
index 1fd9b46..b32419a 100644
|
|
--- a/src/session.ts
|
|
+++ b/src/session.ts
|
|
@@ -1,25 +1,29 @@
|
|
+import type { BindType, LinkEnd, SessionBind } from './bind-direction.ts';
|
|
import type { ErrorName } from './defs/errors.ts';
|
|
+import type { LinkEffect, LinkEvent } from './link-life.ts';
|
|
import type { MessageDlr } from './dlr-merger.ts';
|
|
import type { ParamValue } from './defs/types.ts';
|
|
import type { PduObject, PduObjectInput, TlvInputs } from './pdu.ts';
|
|
import type { PduRefusedError } from './pdu-refusal.ts';
|
|
-import type { BindType, CloseOptions, LinkEnd, ReconnectOptions, SendOptions, SessionBind, SessionEvents, SessionOptions } from './session-options.ts';
|
|
+import type { CloseOptions, ReconnectOptions, SendOptions, SessionEvents, SessionOptions } from './session-options.ts';
|
|
import type { Result, VoidResult } from './result.ts';
|
|
import type { SendSmsOptions, SendSmsResult } from './send-sms.ts';
|
|
import type { SmppLog } from './log.ts';
|
|
import type { Socket } from 'node:net';
|
|
import { DlrMerger } from './dlr-merger.ts';
|
|
import { EventEmitter } from 'node:events';
|
|
+import { HeldMessages } from './held-messages.ts';
|
|
import { IncomingRequests } from './incoming-requests.ts';
|
|
import { LinkLife } from './link-life.ts';
|
|
import { LinkTimers } from './link-timers.ts';
|
|
import { OutgoingRequests } from './outgoing-requests.ts';
|
|
import { PduTransport } from './pdu-transport.ts';
|
|
import { ReconnectLoop } from './reconnect-loop.ts';
|
|
-import { leftOf } from './idle-waiters.ts';
|
|
+import { bindCarries, bindCommands, checkedBind } from './bind-direction.ts';
|
|
+import { drain } from './drain.ts';
|
|
import { errorFrom } from './error-from.ts';
|
|
import { optionalParamsMinVersion } from './defs/constants.ts';
|
|
-import { bindCarries, bindCommands, checkedBind, defaultSystemId, defaults } from './session-options.ts';
|
|
+import { defaultSystemId, defaults } from './session-options.ts';
|
|
import { isResp, objToPdu, pduReturn } from './pdu.ts';
|
|
import { refusalAnswer } from './pdu-refusal.ts';
|
|
import { guardedLog } from './log.ts';
|
|
@@ -61,6 +65,7 @@ export class Session extends EventEmitter<SessionEvents> {
|
|
|
|
private readonly concatReference = new ConcatReference();
|
|
private readonly dlrMerger: DlrMerger;
|
|
+ private readonly held: HeldMessages;
|
|
private readonly incoming: IncomingRequests;
|
|
private readonly link: LinkLife;
|
|
private readonly options: SessionOptions;
|
|
@@ -98,7 +103,7 @@ export class Session extends EventEmitter<SessionEvents> {
|
|
|
|
this.log.error('session - a listener rejected', { event, message: error.message });
|
|
|
|
- if (event === 'sms') this.incoming.listenerRejected(rest[0]);
|
|
+ if (event === 'sms') this.held.listenerRejected(rest[0]);
|
|
|
|
if (event !== 'sessionError') this.emit('sessionError', error);
|
|
}
|
|
@@ -119,8 +124,7 @@ export class Session extends EventEmitter<SessionEvents> {
|
|
idleTimeout: options.idleTimeout,
|
|
log: this.log,
|
|
onEnquireLink: () => { void this.send({ cmdName: 'enquire_link' }); },
|
|
- // Not close(): a link that went quiet is a drop, and a drop is what reconnect is for.
|
|
- onIdle: () => { this.teardown(); },
|
|
+ onIdle: () => { this.apply('lost'); },
|
|
});
|
|
this.transport = this.transportFor(options.sock);
|
|
this.outgoing = new OutgoingRequests({
|
|
@@ -130,21 +134,34 @@ export class Session extends EventEmitter<SessionEvents> {
|
|
responseTimeout,
|
|
transport: this.transport,
|
|
});
|
|
- this.incoming = new IncomingRequests({
|
|
+ this.held = new HeldMessages({
|
|
+ linkGeneration: () => this.link.generation(),
|
|
+ log: this.log,
|
|
+ max: defaults.maxHeldMessages,
|
|
+ maxOctets: defaults.maxHeldOctets,
|
|
+ sendReceipt: input => this.outgoing.requestPastDrain(input, {}),
|
|
+ session: this,
|
|
+ timeout: defaults.heldMessageTimeout,
|
|
+ });
|
|
+ this.incoming = this.incomingFor(options, this.held);
|
|
+
|
|
+ this.timers.reset();
|
|
+ }
|
|
+
|
|
+ private incomingFor(options: SessionOptions, held: HeldMessages): IncomingRequests {
|
|
+ return new IncomingRequests({
|
|
dlrMerger: this.dlrMerger,
|
|
+ held,
|
|
link: this.link,
|
|
log: this.log,
|
|
maxOctets: options.maxOctets,
|
|
maxReassembly: options.maxReassembly,
|
|
onRequest: options.onRequest,
|
|
reassemblyTimeout: options.reassemblyTimeout,
|
|
- sendPastDrain: input => this.outgoing.requestPastDrain(input, {}),
|
|
session: this,
|
|
smsIdFormat: options.smsIdFormat,
|
|
systemId: options.systemId,
|
|
});
|
|
-
|
|
- this.resetTimers();
|
|
}
|
|
|
|
/** Replaced on reconnect, so hold the session rather than this. */
|
|
@@ -196,22 +213,6 @@ export class Session extends EventEmitter<SessionEvents> {
|
|
return Promise.resolve(this.answer(pduReturn(pdu, status, params, tlvs), pdu.cmdName, pdu.seqNr));
|
|
}
|
|
|
|
- private answer(built: Result<{ buffer: Buffer }>, cmdName: string, seqNr: number): VoidResult {
|
|
- const sent = built.err ? { err: built.err } : this.transport.write(built.buffer);
|
|
-
|
|
- // A peer that unbinds and drops the link takes our response with it; that is not a failure.
|
|
- if (sent.err && this.link.isAttached()) {
|
|
- this.log.warn('session - could not answer a request', {
|
|
- cmdName,
|
|
- message: sent.err.message,
|
|
- seqNr,
|
|
- });
|
|
- this.emit('sessionError', sent.err);
|
|
- }
|
|
-
|
|
- return sent;
|
|
- }
|
|
-
|
|
async sendSms(sms: SendSmsOptions, options: SendOptions = {}): Promise<SendSmsResult> {
|
|
if (!this.bindAllows('submit_sm')) {
|
|
return unsent(new Error('A receiver-bound session does not carry submit_sm'));
|
|
@@ -235,13 +236,13 @@ export class Session extends EventEmitter<SessionEvents> {
|
|
*/
|
|
async unbind(): Promise<VoidResult> {
|
|
const drained = await this.drain(undefined);
|
|
- const wasOpen = this.link.isAttached();
|
|
+ const wasOpen = this.link.attached();
|
|
const sent = wasOpen
|
|
? await this.outgoing.requestOnCurrentLink({ cmdName: 'unbind' })
|
|
: { err: new Error('Session is closed') };
|
|
- const closedOnUnbind = wasOpen && !this.link.isAttached();
|
|
+ const closedOnUnbind = wasOpen && !this.link.attached();
|
|
|
|
- this.end();
|
|
+ this.apply('closed');
|
|
|
|
return sent.err && !closedOnUnbind ? { err: sent.err } : drained;
|
|
}
|
|
@@ -253,15 +254,84 @@ export class Session extends EventEmitter<SessionEvents> {
|
|
async close(options: CloseOptions = {}): Promise<VoidResult> {
|
|
const drained = await this.drain(options.signal);
|
|
|
|
- this.end();
|
|
+ this.apply('closed');
|
|
|
|
return drained;
|
|
}
|
|
|
|
+ /** Runs the link's transition and then, in order, what it asks of the session. */
|
|
+ private apply(event: LinkEvent): void {
|
|
+ for (const effect of this.link.transition(event)) {
|
|
+ this.run(effect);
|
|
+ }
|
|
+ }
|
|
+
|
|
+ private run(effect: LinkEffect): void {
|
|
+ switch (effect) {
|
|
+ case 'dropLink':
|
|
+ this.timers.clear();
|
|
+ this.held.clear();
|
|
+ this.incoming.clear();
|
|
+ this.outgoing.linkLost();
|
|
+ this.sock.destroy();
|
|
+ break;
|
|
+ case 'emitClose':
|
|
+ this.dlrMerger.clear();
|
|
+ this.emit('close');
|
|
+ break;
|
|
+ case 'emitDisconnected':
|
|
+ this.emit('disconnected');
|
|
+ break;
|
|
+ case 'linkUp':
|
|
+ this.timers.reset();
|
|
+ this.log.info('session - reconnected');
|
|
+ this.emit('reconnected');
|
|
+ break;
|
|
+ case 'scheduleReconnect':
|
|
+ this.reconnectLoop?.schedule();
|
|
+ break;
|
|
+ case 'stopReconnect':
|
|
+ this.reconnectLoop?.stop();
|
|
+ break;
|
|
+ }
|
|
+ }
|
|
+
|
|
+ /** Stops new sends before its first await, then waits out what the session holds. */
|
|
+ private drain(signal: AbortSignal | undefined): Promise<VoidResult> {
|
|
+ this.apply('stopping');
|
|
+
|
|
+ return drain({
|
|
+ linkCarries: () => this.outgoing.canCarry(),
|
|
+ messagesUnanswered: (timeout, cut) => this.held.idle(timeout, cut),
|
|
+ requestsUnfinished: (timeout, cut) => this.outgoing.idle(timeout, cut),
|
|
+ }, {
|
|
+ log: this.log,
|
|
+ responseTimeout: this.options.responseTimeout ?? defaults.responseTimeout,
|
|
+ shutdownTimeout: this.options.shutdownTimeout ?? defaults.shutdownTimeout,
|
|
+ signal,
|
|
+ });
|
|
+ }
|
|
+
|
|
+ private answer(built: Result<{ buffer: Buffer }>, cmdName: string, seqNr: number): VoidResult {
|
|
+ const sent = built.err ? { err: built.err } : this.transport.write(built.buffer);
|
|
+
|
|
+ // A peer that unbinds and drops the link takes our response with it; that is not a failure.
|
|
+ if (sent.err && this.link.attached()) {
|
|
+ this.log.warn('session - could not answer a request', {
|
|
+ cmdName,
|
|
+ message: sent.err.message,
|
|
+ seqNr,
|
|
+ });
|
|
+ this.emit('sessionError', sent.err);
|
|
+ }
|
|
+
|
|
+ return sent;
|
|
+ }
|
|
+
|
|
private transportFor(sock: Socket): PduTransport {
|
|
return new PduTransport({
|
|
log: this.log,
|
|
- onClose: () => { this.onClose(); },
|
|
+ onClose: () => { this.apply('lost'); },
|
|
onData: chunk => { this.onData(chunk); },
|
|
onError: err => { this.emit('sessionError', err); },
|
|
onFramed: pdu => { this.emit('incomingPdu', pdu); },
|
|
@@ -269,7 +339,7 @@ export class Session extends EventEmitter<SessionEvents> {
|
|
onRefused: refused => { this.refuse(refused); },
|
|
onUnreadable: err => {
|
|
this.emit('sessionError', err);
|
|
- this.teardown();
|
|
+ this.apply('lost');
|
|
},
|
|
}, sock);
|
|
}
|
|
@@ -290,109 +360,27 @@ export class Session extends EventEmitter<SessionEvents> {
|
|
sock: Socket,
|
|
bind: (session: Session) => Promise<VoidResult>,
|
|
): Promise<VoidResult> {
|
|
- this.attach(sock);
|
|
+ this.transport.attach(sock);
|
|
+ this.apply('attached');
|
|
|
|
const bound = await bind(this);
|
|
|
|
if (bound.err) {
|
|
- this.teardown();
|
|
+ this.apply('lost');
|
|
|
|
return { err: bound.err };
|
|
}
|
|
|
|
- // close() can land while the rebind is in flight.
|
|
- if (!this.link.retrying()) {
|
|
- this.teardown();
|
|
-
|
|
- return { err: new Error('Session closed while it was coming back up') };
|
|
- }
|
|
-
|
|
- this.resetTimers();
|
|
- this.link.open();
|
|
- this.log.info('session - reconnected');
|
|
- this.emit('reconnected');
|
|
-
|
|
- return {};
|
|
- }
|
|
-
|
|
- private attach(sock: Socket): void {
|
|
- this.transport.attach(sock);
|
|
- this.link.attach();
|
|
- }
|
|
-
|
|
- /** Stops new sends and waits out the messages we hold and the requests already issued. */
|
|
- private async drain(signal: AbortSignal | undefined): Promise<VoidResult> {
|
|
- this.stop();
|
|
-
|
|
- // No bound link, so nothing is on the wire to wait out.
|
|
- if (!this.outgoing.canCarry()) return {};
|
|
-
|
|
- const timeout = this.options.shutdownTimeout ?? defaults.shutdownTimeout;
|
|
- const deadline = timeout > 0 ? Date.now() + timeout : 0;
|
|
- // Answering a message can put a receipt on the wire; nothing on the wire produces a message.
|
|
- const messages = await this.incoming.drain(this.answering(timeout), signal);
|
|
- const requests = await this.outgoing.drain(leftOf(deadline), signal);
|
|
-
|
|
- // The link went before the drain finished, so an empty window says nothing about the peer.
|
|
- if (!this.outgoing.canCarry()) {
|
|
- return { err: new Error('The session closed before the drain finished') };
|
|
- }
|
|
-
|
|
- 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. */
|
|
- private answering(timeout: number): number {
|
|
- if (timeout > 0) return timeout;
|
|
-
|
|
- const responseTimeout = this.options.responseTimeout ?? defaults.responseTimeout;
|
|
+ this.apply('bound');
|
|
|
|
- return responseTimeout > 0 ? responseTimeout : defaults.responseTimeout;
|
|
- }
|
|
-
|
|
- /** The session is over now, drained or not. Nothing brings it back. */
|
|
- private end(): void {
|
|
- this.stop();
|
|
- this.teardown();
|
|
- this.dlrMerger.clear();
|
|
- this.emitClose();
|
|
- }
|
|
-
|
|
- /** No new sends, and no link after this one. */
|
|
- private stop(): void {
|
|
- this.link.stop();
|
|
- this.reconnectLoop?.stop();
|
|
- }
|
|
-
|
|
- private emitClose(): void {
|
|
- if (!this.link.end()) return;
|
|
-
|
|
- this.outgoing.linkLost();
|
|
- this.emit('close');
|
|
- }
|
|
-
|
|
- private teardown(): void {
|
|
- const lost = this.link.drop();
|
|
-
|
|
- if (!lost) return;
|
|
-
|
|
- this.outgoing.linkLost();
|
|
- this.timers.clear();
|
|
- this.incoming.clear();
|
|
- this.sock.destroy();
|
|
-
|
|
- // `lost` is read before clear(): a listener it reaches may close() the session, and the drop still reports as disconnected.
|
|
- if (lost === 'disconnected') this.emit('disconnected');
|
|
- else this.emitClose();
|
|
+ // close() can land while the rebind is in flight, and 'bound' then ends the link instead.
|
|
+ return this.link.carries() ? {} : { err: new Error('Session closed while it was coming back up') };
|
|
}
|
|
|
|
private onData(chunk: Buffer): void {
|
|
this.emit('data', chunk);
|
|
- this.resetTimers();
|
|
+
|
|
+ if (this.link.attached()) this.timers.reset();
|
|
}
|
|
|
|
private dispatch(pduObj: PduObject): void {
|
|
@@ -433,21 +421,4 @@ export class Session extends EventEmitter<SessionEvents> {
|
|
|
|
this.answer(objToPdu({ ...refusalAnswer(refused), seqNr }), cmdName ?? String(cmdId), seqNr);
|
|
}
|
|
-
|
|
- private resetTimers(): void {
|
|
- if (!this.link.isAttached()) return;
|
|
-
|
|
- this.timers.reset();
|
|
- }
|
|
-
|
|
- private onClose(): void {
|
|
- if (this.link.retrying()) {
|
|
- this.teardown();
|
|
- this.reconnectLoop?.schedule();
|
|
-
|
|
- return;
|
|
- }
|
|
-
|
|
- this.end();
|
|
- }
|
|
}
|
|
diff --git a/test/session-extras.test.ts b/test/session-extras.test.ts
|
|
index 6b36279..25d8f74 100644
|
|
--- a/test/session-extras.test.ts
|
|
+++ b/test/session-extras.test.ts
|
|
@@ -5,7 +5,7 @@ import type { Collected, LostGroup } from '../src/reassembly.ts';
|
|
import type { Dlr } from '../src/dlr.ts';
|
|
import type { ErrorName } from '../src/defs/errors.ts';
|
|
import type { IncomingRequestsOptions } from '../src/incoming-requests.ts';
|
|
-import type { HeldMessagesOptions, MessageHold } from '../src/held-messages.ts';
|
|
+import type { HeldMessagesOptions } from '../src/held-messages.ts';
|
|
import type { MessageState } from '../src/defs/constants.ts';
|
|
import type { MessageDlr } from '../src/session.ts';
|
|
import type { PduObject, PduObjectInput } from '../src/pdu.ts';
|
|
@@ -26,7 +26,8 @@ import { Session } from '../src/session.ts';
|
|
import { DlrMerger } from '../src/dlr-merger.ts';
|
|
import { PduRefusedError } from '../src/pdu-refusal.ts';
|
|
import { objToPdu } from '../src/pdu.ts';
|
|
-import { checkSessionOptions, defaults, standsInFor } from '../src/session-options.ts';
|
|
+import { checkSessionOptions, defaults } from '../src/session-options.ts';
|
|
+import { standsInFor } from '../src/bind-direction.ts';
|
|
import { client } from '../src/client.ts';
|
|
import { closeAfter, closeListenerAfter } from './teardown.ts';
|
|
import { concatOf } from '../src/concat.ts';
|
|
@@ -101,12 +102,30 @@ function abortAfter(
|
|
});
|
|
}
|
|
|
|
+function heldMessagesOn(
|
|
+ session: Session,
|
|
+ options: Partial<Pick<HeldMessagesOptions, 'log' | 'max' | 'maxOctets' | 'now' | 'timeout'>> = {},
|
|
+): HeldMessages {
|
|
+ return new HeldMessages({
|
|
+ linkGeneration: () => 0,
|
|
+ log: silentLog,
|
|
+ max: defaults.maxHeldMessages,
|
|
+ maxOctets: defaults.maxHeldOctets,
|
|
+ sendReceipt: () => Promise.resolve({ err: new Error('never sent') }),
|
|
+ session,
|
|
+ timeout: defaults.heldMessageTimeout,
|
|
+ ...options,
|
|
+ });
|
|
+}
|
|
+
|
|
function incomingOn(session: Session, options: Partial<IncomingRequestsOptions> = {}): IncomingRequests {
|
|
+ const log = options.log ?? silentLog;
|
|
+
|
|
return new IncomingRequests({
|
|
- dlrMerger: new DlrMerger({ log: silentLog, max: 10, timeout: 10_000 }),
|
|
- link: new LinkLife({ log: silentLog, reconnects: false, timeout: 100 }),
|
|
- log: silentLog,
|
|
- sendPastDrain: () => Promise.resolve({ err: new Error('never sent') }),
|
|
+ dlrMerger: new DlrMerger({ log, max: 10, timeout: 10_000 }),
|
|
+ held: heldMessagesOn(session, { log }),
|
|
+ link: new LinkLife({ log, reconnects: false, timeout: 100 }),
|
|
+ log,
|
|
session,
|
|
...options,
|
|
});
|
|
@@ -757,7 +776,7 @@ describe('reconnect', () => {
|
|
|
|
const handled = incoming.handle(submitPdu(1));
|
|
|
|
- link.drop();
|
|
+ link.transition('lost');
|
|
|
|
await handled;
|
|
|
|
@@ -1407,9 +1426,9 @@ describe('LinkLife', () => {
|
|
test('refuses a hold whose deadline has already passed', async () => {
|
|
let now = 0;
|
|
const link = new LinkLife({ log: silentLog, now: () => now, reconnects: true, timeout: 100 });
|
|
- const waitForLink = link.hold(undefined);
|
|
+ const waitForLink = link.budget(undefined);
|
|
|
|
- link.drop();
|
|
+ link.transition('lost');
|
|
now = 101;
|
|
|
|
const held = await waitForLink();
|
|
@@ -1421,14 +1440,15 @@ describe('LinkLife', () => {
|
|
const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 10_000 });
|
|
const timers = (): number => process.getActiveResourcesInfo().filter(name => name === 'Timeout').length;
|
|
|
|
- link.drop();
|
|
+ link.transition('lost');
|
|
|
|
const before = timers();
|
|
- const held = link.hold(undefined)();
|
|
+ const held = link.budget(undefined)();
|
|
|
|
assert.equal(timers(), before + 1, 'an unref\'d timer is not counted here, which is the point');
|
|
|
|
- link.open();
|
|
+ link.transition('attached');
|
|
+ link.transition('bound');
|
|
|
|
assert.deepEqual(await held, {});
|
|
});
|
|
@@ -1437,9 +1457,9 @@ describe('LinkLife', () => {
|
|
test('gives up at once on a signal that was already aborted', async () => {
|
|
const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 100 });
|
|
|
|
- link.drop();
|
|
+ link.transition('lost');
|
|
|
|
- const held = await link.hold(AbortSignal.abort())();
|
|
+ const held = await link.budget(AbortSignal.abort())();
|
|
|
|
assert.match(held.err?.message ?? '', /Aborted while waiting for a link/);
|
|
});
|
|
@@ -1448,47 +1468,58 @@ describe('LinkLife', () => {
|
|
const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 100 });
|
|
|
|
assert.equal(link.awaitsNextLink(), false, 'up');
|
|
- link.drop();
|
|
+ link.transition('lost');
|
|
assert.equal(link.awaitsNextLink(), true, 'down, returning');
|
|
- link.attach();
|
|
+ link.transition('attached');
|
|
assert.equal(link.awaitsNextLink(), true, 'attached, not yet bound');
|
|
- link.open();
|
|
+ link.transition('bound');
|
|
assert.equal(link.awaitsNextLink(), false, 'reopened');
|
|
- link.drop();
|
|
- link.stop();
|
|
+ link.transition('lost');
|
|
+ link.transition('stopping');
|
|
assert.equal(link.awaitsNextLink(), false, 'down, stopped');
|
|
assert.match(link.refusal()?.message ?? '', /closed/, 'stopped while down');
|
|
- link.end();
|
|
+ link.transition('closed');
|
|
assert.equal(link.awaitsNextLink(), false, 'ended');
|
|
});
|
|
|
|
- test('drops an attached link once, counts each drop, and names the event it warrants', () => {
|
|
+ test('drops an attached link once, counts each drop, and names what the session does next', () => {
|
|
const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 100 });
|
|
const generation = link.generation();
|
|
|
|
- assert.equal(link.drop(), 'disconnected');
|
|
- assert.equal(link.drop(), undefined, 'already down');
|
|
+ assert.deepEqual(link.transition('lost'), ['dropLink', 'emitDisconnected', 'scheduleReconnect']);
|
|
+ assert.deepEqual(link.transition('lost'), [], 'already down');
|
|
assert.equal(link.generation(), generation + 1);
|
|
- link.attach();
|
|
- link.stop();
|
|
- assert.equal(link.drop(), 'close', 'a new link drops again, with none to follow it');
|
|
+ assert.deepEqual(link.transition('attached'), []);
|
|
+ assert.deepEqual(link.transition('stopping'), ['stopReconnect']);
|
|
+ assert.deepEqual(link.transition('lost'), ['dropLink', 'emitClose'], 'a new link drops again, with none to follow it');
|
|
assert.equal(link.generation(), generation + 2);
|
|
- assert.equal(new LinkLife({ log: silentLog, reconnects: false, timeout: 100 }).drop(), 'close');
|
|
+ assert.equal(link.phase, 'closed');
|
|
+ assert.deepEqual(new LinkLife({ log: silentLog, reconnects: false, timeout: 100 }).transition('lost'), ['dropLink', 'emitClose']);
|
|
+ });
|
|
+
|
|
+ test('ends a link bound after the shutdown began, rather than bringing it up', () => {
|
|
+ const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 100 });
|
|
+
|
|
+ link.transition('lost');
|
|
+ link.transition('attached');
|
|
+ link.transition('stopping');
|
|
+
|
|
+ assert.deepEqual(link.transition('bound'), ['dropLink', 'emitClose']);
|
|
+ assert.equal(link.carries(), false);
|
|
});
|
|
|
|
test('releases a held request with the reason once the link ends', async () => {
|
|
const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 0 });
|
|
|
|
- link.drop();
|
|
+ link.transition('lost');
|
|
|
|
- const held = link.hold(undefined)();
|
|
-
|
|
- link.end();
|
|
+ const held = link.budget(undefined)();
|
|
|
|
+ assert.deepEqual(link.transition('closed'), ['emitClose']);
|
|
assert.match((await held).err?.message ?? '', /Session is closed/);
|
|
- link.attach();
|
|
- assert.equal(link.isAttached(), false, 'ended is final');
|
|
- assert.equal(link.end(), false);
|
|
+ link.transition('attached');
|
|
+ assert.equal(link.attached(), false, 'ended is final');
|
|
+ assert.deepEqual(link.transition('closed'), []);
|
|
});
|
|
});
|
|
|
|
@@ -1542,12 +1573,12 @@ describe('held message bounds', () => {
|
|
return [submitPdu(seqNr)];
|
|
}
|
|
|
|
- function offer(held: HeldMessages, seqNr: number): MessageHold {
|
|
- const hold = held.offer(message(seqNr));
|
|
+ function offer(held: HeldMessages, seqNr: number): Sms {
|
|
+ const sms = held.offer(message(seqNr));
|
|
|
|
- assert.ok(hold);
|
|
+ assert.ok(sms);
|
|
|
|
- return hold;
|
|
+ return sms;
|
|
}
|
|
|
|
/** Offers to a session with a listener, so an offer is held rather than released as untaken. */
|
|
@@ -1559,17 +1590,12 @@ describe('held message bounds', () => {
|
|
|
|
closeAfter(t, session);
|
|
session.on('sms', () => undefined);
|
|
+ session.sendReturn = () => Promise.resolve({});
|
|
|
|
- return new HeldMessages({
|
|
- ...options,
|
|
- link: new LinkLife({ log: silentLog, reconnects: false, timeout: 100 }),
|
|
- log: silentLog,
|
|
- sendPastDrain: () => Promise.resolve({ err: new Error('never sent') }),
|
|
- session,
|
|
- });
|
|
+ return heldMessagesOn(session, options);
|
|
}
|
|
|
|
- test('is full at its count, and a re-used sequence number replaces rather than adding', t => {
|
|
+ test('is full at its count, and a re-used sequence number replaces rather than adding', async t => {
|
|
const held = heldOn(t, { max: 2, maxOctets: 1_000_000, timeout: 10_000 });
|
|
const first = offer(held, 1);
|
|
const replaced = offer(held, 2);
|
|
@@ -1579,14 +1605,16 @@ describe('held message bounds', () => {
|
|
assert.equal(held.size, 2);
|
|
assert.equal(held.octetsHeld, 2 * 1026, 'the replaced message leaves its octets with it');
|
|
assert.equal(held.full(), true);
|
|
- assert.equal(first.isHeld(), true);
|
|
- assert.equal(replaced.isHeld(), false);
|
|
+ await replaced.sendResp();
|
|
+ assert.equal(held.size, 2, 'answering the replaced message releases nothing');
|
|
+ await first.sendResp();
|
|
+ assert.equal(held.size, 1, 'answering the first releases it');
|
|
|
|
held.clear();
|
|
});
|
|
|
|
// submitPdu() holds 1026 octets by the maxOctets charge: its object, and the three text fields.
|
|
- test('is full at its octet cap, until a message leaves by any way out', t => {
|
|
+ test('is full at its octet cap, until a message leaves by any way out', async t => {
|
|
let now = 0;
|
|
const held = heldOn(t, { max: 10, maxOctets: 2000, now: () => now, timeout: 10_000 });
|
|
const answered = offer(held, 1);
|
|
@@ -1595,8 +1623,8 @@ describe('held message bounds', () => {
|
|
offer(held, 2);
|
|
assert.equal(held.full(), true);
|
|
|
|
- answered.release();
|
|
- assert.equal(held.full(), false, 'after a release');
|
|
+ await answered.sendResp();
|
|
+ assert.equal(held.full(), false, 'after an answer');
|
|
offer(held, 3);
|
|
|
|
now = 20_000;
|