From 576b713af47564670d3dc73e772f143e223d8668 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Wed, 30 Sep 2026 12:04:56 +0200 Subject: [PATCH] Plan the comprehension rewrite and file its background --- docs/comprehension-rewrite/board.md | 315 + .../drafts/draft-a.patch | 2726 ++++ .../drafts/draft-b.patch | 1971 +++ .../drafts/draft-c.patch | 9078 ++++++++++++ .../drafts/draft-d.patch | 6204 ++++++++ .../drafts/draft-e.patch | 7694 ++++++++++ .../drafts/draft-f.patch | 12257 ++++++++++++++++ docs/comprehension-rewrite/lessons.md | 59 + .../panels/round-1-drafts-a-b.md | 605 + .../panels/round-2-drafts-c-d.md | 658 + .../panels/round-3-drafts-e-f.md | 710 + docs/comprehension-rewrite/plan-1.md | 287 + docs/comprehension-rewrite/plan-2.md | 287 + docs/comprehension-rewrite/plan-3.md | 241 + todo.md | 64 +- 15 files changed, 43142 insertions(+), 14 deletions(-) create mode 100644 docs/comprehension-rewrite/board.md create mode 100644 docs/comprehension-rewrite/drafts/draft-a.patch create mode 100644 docs/comprehension-rewrite/drafts/draft-b.patch create mode 100644 docs/comprehension-rewrite/drafts/draft-c.patch create mode 100644 docs/comprehension-rewrite/drafts/draft-d.patch create mode 100644 docs/comprehension-rewrite/drafts/draft-e.patch create mode 100644 docs/comprehension-rewrite/drafts/draft-f.patch create mode 100644 docs/comprehension-rewrite/lessons.md create mode 100644 docs/comprehension-rewrite/panels/round-1-drafts-a-b.md create mode 100644 docs/comprehension-rewrite/panels/round-2-drafts-c-d.md create mode 100644 docs/comprehension-rewrite/panels/round-3-drafts-e-f.md create mode 100644 docs/comprehension-rewrite/plan-1.md create mode 100644 docs/comprehension-rewrite/plan-2.md create mode 100644 docs/comprehension-rewrite/plan-3.md diff --git a/docs/comprehension-rewrite/board.md b/docs/comprehension-rewrite/board.md new file mode 100644 index 0000000..92964e1 --- /dev/null +++ b/docs/comprehension-rewrite/board.md @@ -0,0 +1,315 @@ +# Board on the three rewrite plans + +## Junior seat + +**Junior seat** (about 2 years of TypeScript, no SMPP) + +My read of main: `link-life.ts` has 7 predicates over a phase plus a separate `stopped` flag, and the initial `'up'` breaks its own rule. `expiring-groups.ts` has a header that says what it does *not* enforce. `session.ts` routes `captureRejections` for `sms` into `incoming.listenerRejected`. The README's "Receive SMS" section tells me to call `sendResp()` on a multipart message that "puts nothing on the wire". I agree with 6 overall and Locality 5. + +## Plan 1: folders follow the README +1. **Would it help? Marginal, close to yes.** Folders named after the README sections are the first layout I could find things in without asking. The problem is answering: I can answer with `sendResp()`, by returning, or by throwing, and `answeredOnArrival` is still there. That is D's "answered in three places" again, only now behind `OwedAnswer`. +2. **Predicted scores** + - Nav 7: README section maps to folder. + - Loc 6: the answer path spans `receive-message.ts`, `running-handlers.ts`, `owed-answer.ts` and `sms.ts`. + - Shape 6: the `AnswerPort`/`SendPort` ports are indirection I have to learn. + - Self 6: citations get summaries, but the glossary sits in the README, and lessons.md says that did not lift juniors. + - Overall 6. +3. **Where I would still get stuck:** `receiving/running-handlers.ts` together with `session/owed-answer.ts`. What happens when `sendResp()` is called and the handler then throws? +4. **Wrong or vague** + - The "two SmsSenders" question is left open until chunk 6. + - `client()` returns a `ClientSession` but still calls it `session`, and `sock`/`sendReturn` move to `session.link`. I would not know which object to listen on. + - Keeping `sendResp()` next to "return answers" gives two ways to answer. + - The no-handler refusal turns a transceiver that never wanted inbound messages into an endless SMSC retry loop. + +## Plan 2: state ownership first +1. **Would it help? Marginal.** It keeps one public `Session` over many internal `Link`s, which brings back the thing F removed: the lifecycle is still split over two fields. It adds `LinkOwner`, five callbacks implemented in another file, which is E's lesson again. `session.ts` stays the hub for handover, link-wait, reconnect and the merger. +2. **Predicted scores** + - Nav 6: the Session versus Link vocabulary. `sms.ts` sits in `session/` while `handlers.ts` sits in `link/`. + - Loc 6: there is one answer ledger, but handover is split across `adopt`/`onGone` and the callbacks. + - Shape 6: four request lanes are still four rules. + - Self 6: `wire/fields.ts` helps, but the glossary sits away from the code in `docs/`. + - Overall 6. +3. **Where I would still get stuck:** `session/session.ts` handover together with `link/link.ts`'s `LinkOwner`. +4. **Wrong or vague** + - This plan has the smallest API break of the three, and that is the best part for an application developer. + - The `sendDlr`-before-answer error will surprise anyone writing a test SMSC. + - The reassembly store refusing its own entry is a behaviour change with no decision written yet. + +## Plan 3: the newcomer's lens +1. **Would it help? Yes.** `protocol/vocabulary.ts` puts TSDoc on hover, and all bit masks stay inside `protocol/`. `data-coding.ts` becomes a table with a "why" column, and a test fails on any bare citation. That attacks the Self-sufficiency 5 that capped juniors. `Reply` as a return value makes "one answer" a type. +2. **Predicted scores** + - Nav 7: protocol, codec, messages, session is a reading order. + - Loc 6: `client/client.ts` gathers the current session, the merge, the reference counter, the retry loop, re-emitting, `fromStart` and abort. + - Shape 7: forward-only session, a store that refuses, `Reply` as the one path. + - Self 7: definitions at the point of use. + - Overall 6, one refactor short of 7. +3. **Where I would still get stuck:** the request loop and event re-emitting in `client/client.ts`. The plan itself predicts this. +4. **Wrong or vague** + - It has six breaks. A1 renames `{ session }` to `{ client }`, which breaks every README example for no comprehension gain beyond F, which scored the same as main. + - A3's `Reply.dlr` is a second way to send a receipt next to `sendDlr()`. + - A4 removes `sendReturn()`, the escape hatch for hand-wired users. + - `handlerTimeout` appears in `handlers.ts` but is missing from the API table. + - Refuse-not-evict can block multipart traffic for `reassemblyTimeout`, which regresses goal 4. + - `waiting.ts` extracts the abort dance, contradicting a recorded decision without saying so. + - A6 (`'ASCII'` to `'GSM7'`) is justified for me as a reader, but it is optional. + +## Verdict +- **Ranking:** Plan 3, Plan 1, Plan 2. +- **Can the best reach 7?** Plausibly, about a coin flip. The single change that most raises its odds: move the retry loop and the link-wait out of `client/client.ts` into their own file, `client/next-link.ts` as in Plan 1, so `SmppClient` only holds and forwards. Dropping A1 and A3 would also stop the application-developer complaints from costing Shape. +- **Structure or intrinsic difficulty?** Mostly structure. Each round moved the hardness around while the SMPP-to-plain-types translation was never in one place. The truly intrinsic parts are small and can be localized: per-segment answers on arrival, the drain's two budgets, and retrying only what was never written. For a junior, the rest of the ceiling was missing domain vocabulary. That is a structural choice about where meaning lives, not a property of the problem. + +PLAN1 helps=marginal nav=7 loc=6 shape=6 self=6 overall=6 +PLAN2 helps=marginal nav=6 loc=6 shape=6 self=6 overall=6 +PLAN3 helps=yes nav=7 loc=6 shape=7 self=7 overall=6 + +## Mid seat + +**Mid seat (5 years TypeScript, never read the SMPP spec)** + +My view of main today: 6. I can find my way around the flat `src/`, and `session.ts` is readable at the top. `LinkLife` is where I stall. It has a phase, a `stopped` flag, seven predicates and an initial `'up'` that breaks its own rule. `ExpiringGroups` is the other place: its doc comment says it enforces neither of its own bounds, yet `weigh()` can evict the key the caller is writing. The `captureRejections` routing to `incoming.listenerRejected` also takes me three files to follow. + +## Plan 1: README verbs as folders, a one-socket Session, and ClientSession + +1. **Helps? Marginal, leaning yes.** Folders that mirror the README's table of contents are what I would guess first. `owed-answer.ts` gives "one answer per request" a single writer, and `BoundedStore` stops evicting the caller's own entry. But the one-socket split is F's, which already scored 6.25, and the plan leaves its sharpest follow-up open: which object owns `SmsSender`, to be settled "at chunk 6". +2. **Predicted scores:** + - Navigation 7: the folder names are the README sections. + - Locality 6: an inbound message still crosses `dispatch.ts`, `receive-message.ts`, `running-handlers.ts`, `owed-answer.ts` and `sms.ts`. + - Shape 6: `ClientSession` forwards events, and there are two `SmsSender`s and two ports. + - Self-sufficiency 7: the README glossary, citations with their summaries, and `Invariant:` paragraphs. + - Overall 6. +3. **What would still defeat me:** `client/client.ts`. A `ClientSession` that re-emits the current `Session`'s events, answers `boundAs` through the reconnect gap, and holds a merger fed from the link's `dlr`. It is the "which object do I listen on" problem moved up one layer. +4. **Wrong or vague:** + - `client()` still resolves `{ session }`, but the value is a `ClientSession` and `session.link` is the real `Session`. That name misleads an application developer. + - `sendResp()` is kept, and returning from the handler also answers. That is two ways to answer the same message, and `answeredOnArrival` stays as a third concept. + - `limits/` is an abstraction name I would not look in for the send window. + - The dual `SmsSender` is undecided. + +## Plan 2: state ownership, with a Link inside the public Session + +1. **Helps? Marginal.** The ownership rules are right: one writer, a lifetime `AbortController`, and "emit last". `wire/fields.ts` naming the bit masks helps me more than any glossary. But `Session` still spans many links, which is the unit that capped round two, now split across `session.ts` and `link.ts`. They are joined by `LinkOwner`, a five-callback interface, which is exactly what defeated E. +2. **Predicted scores:** + - Navigation 6: `link/` against `session/` is a distinction I must learn before I can place `handlers.ts`, `sms.ts` or `send-sms.ts`. + - Locality 6: the answering path is in two adjacent files, which is good, but a link going down spans `Link`, `LinkOwner`, `adopt`/`onGone`, `link-wait.ts` and `outbound.ts`. + - Shape 6: four lanes in one table are still four rules. `sock` means "the current or last Link's", and `boundAs` is read "from the last bound Link". + - Self-sufficiency 6: the glossary lives in `docs/glossary.md`, away from the code, and the lessons say an off-code glossary did not lift juniors. + - Overall 6. +3. **What would still defeat me:** `session/session.ts` `adopt`/`onGone`, with `session/outbound.ts`'s retry loop that goes back to `boundLink()`. That is the reconnect lifecycle again, under new names. +4. **Wrong or vague:** + - It keeps the reconnecting `Session` to avoid F's rename. That preserves the exact structure every round-two seat named. + - Changing `sendReturn()` to return `err` in two new cases is a silent behaviour change on an existing call. + - The rule "a `sendDlr()` before the answer returns `err`" breaks test-double servers that report immediately, and the plan admits it. + - The five callbacks and the lane table are not specified. + +## Plan 3: a `protocol/` translation layer, `Reply` return values, and SmppClient + +1. **Helps? Yes.** This is the only plan aimed at my actual cost: the bit masks and SMPP terms, translated once in `protocol/` into typed plain values, with definitions I see on hover in the editor. A citation test enforces that every spec reference carries its sentence. `requests-in.ts replyFor()` is a pure function returning a `Reply`, which makes "one answer per request" a type instead of an agreement between files. Its `Session` is one socket with one forward-only state field. +2. **Predicted scores:** + - Navigation 7: `codec`, `protocol`, `messages`, `session`, `client`, `server` read in order. The weak spots are `retained.ts` and `bounded-store.ts` under `messages/`. + - Locality 7: a reply is decided in one function and written in one place, and data_coding is one table. + - Shape 6: two `sendSms` surfaces, `Reply.dlr` beside `sendDlr()`, and a `SmppClient` hub. + - Self-sufficiency 7: definitions next to their use, and every citation says what it cites. + - Overall 7. +3. **What would still defeat me:** `client/client.ts`. It holds the current session, the receipt merge, the concatenation reference counter, the retry-if-unwritten request loop, event re-emission, `close`/`unbind`, and `fromStart` plus abort. That is seven responsibilities in one file, and the plan itself predicts it becomes the next hardest unit. +4. **Wrong or vague:** + - Six breaking changes is more than "a minimum". A6 (`'ASCII'` renamed to `'GSM7'`) breaks every caller that set an encoding. It is justified by one-spelling-per-goal but not required for comprehension, since the internal rename already gets that gain. + - A3's `Reply.dlr` is a second way to send a receipt. + - Refusing new segments when the reassembly store is full, instead of evicting the oldest group, lets abandoned groups block all multipart traffic until `reassemblyTimeout`. That hurts an operator (goal 4), and the plan does not weigh it against the goal 2 gain. + - Removing `sendReturn()` takes away an escape hatch for low-level users without saying what replaces it beyond "return a `Reply`". + - `retained.ts` sits in `messages/` only because the stores use it. That is proximity, not a real seam. + +## Verdict + +**Ranking:** Plan 3, then Plan 1, then Plan 2. + +**Can the best reach 7 overall?** Plausibly on my seat. A panel mean of 7 is less likely, because the architect seat will cite the `SmppClient` hub and the number of API breaks. The single change that would most raise its odds is to split `SmppClient`'s request loop into its own file (`client/next-link.ts`, as in Plan 1), owning waiting-for-a-bound-session and retry-only-unwritten with its invariant. `client.ts` then only composes, and the predicted next hardest unit never forms. + +**Structure or intrinsic difficulty?** Mostly structure, and specifically the contract that structure was built around. Each round, the named hardest unit was self-inflicted rather than SMPP: +- the held-message timing contract; +- a reconnecting session with duplicated stop flags; +- a store that does not enforce its own bounds; +- answering enforced jointly by two files. + +When the contract changed, the unit moved, and none of the ones named since are protocol facts. The intrinsic part — multipart answered on arrival, the drain's two budgets, retrying only what never reached the socket, and data_coding groups — is real. But it is a handful of localized items, which puts the ceiling near 7–8, not 6. The earlier redesigns stalled because each one left a different piece of shared, unowned state behind. + +PLAN1 helps=marginal nav=7 loc=6 shape=6 self=7 overall=6 +PLAN2 helps=marginal nav=6 loc=6 shape=6 self=6 overall=6 +PLAN3 helps=yes nav=7 loc=7 shape=6 self=7 overall=7 + +## Senior seat + +Plan 3 is the only one I expect to reach 7. Plan 1 is a marginal gain and plan 2 is roughly main plus renames. My seat most likely already gave main its 7, so a rewrite has to beat 7 on this seat to count as help here. + +**How hard main is today, from my seat.** `link-life.ts` shows lesson 3 exactly. It has a 4-value phase starting at `'up'`, a separate `stopped`, and seven predicates. Its `generation()` counter exists only so a reader can tell a link has gone. `session.ts` (453 lines) wires ten collaborators. Its `captureRejectionSymbol` override reaches into `incoming.listenerRejected`. The layout is flat and named well, so Navigation is fine. The cost is in Locality: to know whether a send is safe you need `LinkLife`, `OutgoingRequests` and `Session` open together. + +--- + +## Plan 1: verb folders, one-socket `Session`, `ClientSession` above it, `onSms` plus `sendResp` kept + +**1. Would it help? Marginal.** +- The one-way `Session`, the reconnect union state, `BoundedStore` enforcing its own bounds and one defaults file all remove units the panels named. +- It adds new sources of confusion in their place: + - `client()` still returns a field called `session` that is a `ClientSession`, and `session.link` is a `Session`. Two names now lie. + - Answering has two spellings. The handler can call `sendResp()` or just return, and the return path does "answer `ESME_ROK` if not answered". That is D's "answered in several places" again, now as a getter over OwedAnswers. + - The answer path spans five files in two folders: `dispatch.ts`, `owed-answer.ts`, `receive-message.ts`, `running-handlers.ts` and `sms.ts`. + +**2. Predicted scores** +- **Navigation 7.** The README table of contents mirrors the folder listing, which makes the layout easy to find your way around. `limits/` is the one abstract name. +- **Locality 6.** The one-answer invariant has one writer, but you cannot understand it without the other four files. `AnswerPort` and `SendPort` add a hop. +- **Shape 6.** `ClientSession` forwards a long event list. Whether there is one `SmsSender` or two is left open. +- **Self-sufficiency 7.** Every citation gets a summary, the README gets a glossary, and there is a data-coding table. +- **Overall 7**, one point above the lowest dimension (Locality 6), which is the most the rule allows. It is no better than main on this seat. + +**3. Where I would still get stuck.** `client/client.ts` together with `client/next-link.ts`: `ClientSession` forwarding events from whichever `Session` is current, plus answering `boundAs` through the reconnect gap. Second place: `session/owed-answer.ts` together with `receiving/running-handlers.ts`. + +**4. Wrong or vague** +- `SmsSender` is left as "pick one at chunk 6". That is an ownership question, and the plan's own principle says ownership comes first. +- Returning `ClientSession` under the name `session` is a public API that misleads the developer about what they hold. Either rename it honestly, as plan 3 does, or keep a single `Session`. +- Keeping `sendResp()` alongside the implicit return answer gives two ways to answer, against the one-spelling rule. +- `linkEnd` becoming readonly is justified. + +--- + +## Plan 2: state ownership, a `Link` per socket behind the unchanged public `Session` + +**1. Would it help? Marginal.** +- The strongest ownership rules of the three: one `AbortController` as the single writer of stoppedness, and "a transition finishes before anyone hears about it". +- The smallest API break, with a single answering spelling (`onSms` returning `{smsId}`/`{status}`) and a ledger that refuses a second answer. +- But its structure is draft A (a `Link` object per socket) plus E's weakness (`LinkOwner`, five callbacks implemented in another file). Lesson 3 of the round-three notes already says E's state machine stayed hard because meaning was split across callbacks. +- `session.ts` stays the hub: life, current link, handover, link-wait, window, reconnect, merger. +- `outbound.ts` keeps four "lanes", which were already cited in round 2. + +**2. Predicted scores** +- **Navigation 6.** Readers must learn the Session/Link split. `link/` holds handlers and reassembly, which a reader would not look for there. +- **Locality 6.** A handover is `adopt`/`onGone` in `session.ts` plus the `LinkOwner` callbacks in `link.ts`. Each transition's meaning is split across two files. +- **Shape 6.** Four lanes, a hub `Session`, and a new "refuse own entry" eviction policy. +- **Self-sufficiency 7.** `wire/fields.ts` names the bit masks, and every citation gets its sentence. +- **Overall 6.** + +**3. Where I would still get stuck.** `session/session.ts` (`adopt`/`onGone` and the link handover) read against `link/link.ts`'s `LinkOwner`. Second place: the lane table in `session/outbound.ts`. + +**4. Wrong or vague** +- `sendDlr()` returning `err` before the answer is a trap for test SMSCs that report immediately. The plan admits this and gives only a README pattern as the fix. +- The own-entry refusal changes reassembly behaviour under pressure without a stated goal trade-off. +- It is unclear where `idle-waiters` ends up: the plan says "inlined" in two places. +- It never says whether `generation()`'s replacement ("a message holds its Link") keeps a gone `Link` alive in memory. + +--- + +## Plan 3: a `protocol/` translation layer, answers as `Reply` return values, `SmppClient` above a one-socket `Session` + +**1. Would it help? Yes.** +- It is the only plan that makes "one answer per request" a type. `replyFor()` returns a `Reply`, and `Session.write()` is the single writer. `onRequest` also returns a `Reply`, and `sendReturn` is gone. +- Bit masks never leave `protocol/`, and a test fails on any bare `§x.y.z` citation. That targets the junior Self-sufficiency cap directly. +- `BoundedStore` is kept simple. +- It removes the lifecycle cap the same way F did. + +**2. Predicted scores** +- **Navigation 7.** The areas read in order: protocol → codec → messages → session → client. The only open question is which `sendSms` to call. +- **Locality 7.** Answering, the lifecycle and the drain each live in one function. The exception is the `client.ts` hub. +- **Shape 7.** State sits in three named files and reconnect lives above the socket. `Reply.dlr` is a wart. +- **Self-sufficiency 7.** The glossary is in code, shown on hover, and citations are enforced by a test. `field-types.ts` stays dense. +- **Overall 7.** + +**3. Where I would still get stuck.** `client/client.ts`. It holds the current session, the receipt merge, the segment reference counter, the request loop that retries only unwritten requests, event re-emitting, `close`/`unbind` and `fromStart` plus abort. That is F's `SmppClient` with more loaded onto it, and the lessons predict the hardest unit lands here next. + +**4. Wrong or vague** +- **The async path is not described.** `replyFor()` is described as pure, but `onSms` is asynchronous. The plan never names the one function that carries a message from `replyFor` through `handlers.ts` to `session.write`. Without it, "exactly one answer" spreads back over three files. +- **A6 (`'ASCII'` renamed to `'GSM7'`) is unjustified.** It breaks every caller for a name the code can gloss once, and goal 8 favours a stable surface. +- **A3 (`Reply.dlr`) adds a second way to send a receipt** beside `sms.sendDlr()`. +- **Refuse-not-evict for reassembly** lets a peer that abandons segment groups block all multipart traffic for `reassemblyTimeout`. That is an operator-facing regression under goal 4, and "goal 2 served better" does not hold for traffic we refuse and the peer then gives up on. +- **A1 renames `session` to `client`** on `client()`'s result. That is defensible: it is honest where plan 1 is not, but it is a real migration cost. +- It is silent on goal 9 (a store interface). `BoundedStore`'s shape should not make that goal harder later. + +--- + +## Verdict + +**Ranking:** plan 3, then plan 1, then plan 2. + +**Can the best reach 7 overall?** Plausibly yes, as a mean around 6.75 to 7, if it drops A3 and A6. The single change that would most raise its odds: move the retry-only-unwritten request loop out of `client/client.ts` into its own file, like plan 1's `client/next-link.ts`, with its invariant at the top. The same file or function should also carry the async `onSms` reply path, so that neither `SmppClient` nor the answer path becomes the next hardest unit. + +**Structure or intrinsic difficulty?** Mostly structure. +- The hardest unit moved every round: the held-message flow, then the lifecycle, then `ExpiringGroups` and the answer invariant. Intrinsic difficulty does not move when you reorganise, so a cap that moves each time is coming from coupling. +- Every draft so far kept at least one object that held both the socket and what outlives the socket, or both the answer and its trigger. The panel scores that worst unit. +- The intrinsic core does set a floor: answering each segment on arrival, the drain's two budgets, retrying only what was never written, and `data_coding`. That floor is about 7 and is not what capped the drafts at 6. +- The coarse four-seat integer scale explains why every drop in difficulty looked like no movement at all. + +PLAN1 helps=marginal nav=7 loc=6 shape=6 self=7 overall=7 +PLAN2 helps=marginal nav=6 loc=6 shape=6 self=7 overall=6 +PLAN3 helps=yes nav=7 loc=7 shape=7 self=7 overall=7 + +## Architect seat + +**Board seat: inherited architect.** I read `link-life.ts` (a four-value phase plus `stopped`, seven predicates, and an initial `'up'`), `expiring-groups.ts` (which says outright that it "enforces neither max nor timeout itself") and `session.ts`. My view matches the panel: 6 overall, Locality 6. The hard spots are ones the code created, not ones SMPP forces. + +## Plan 1: folders named after what the developer does, `ClientSession` above a one-socket `Session` + +1. **Would it help?** Marginal. It combines F's one-socket split with D's handler that keeps `sendResp()`, and both scored 6 before. Answering is now spread over four files in two folders: `session/owed-answer.ts`, `receiving/receive-message.ts`, `receiving/running-handlers.ts` and `receiving/sms.ts`. The ports (`AnswerPort`, `SendPort`) add names without removing a step. +2. **Predicted scores:** + - Nav 6: folders named after what the developer does are good. But `client()` still resolves `{ session }`, and that value is a `ClientSession` whose `.link` is the real `Session`, so the name lies at the first line of every example. `limits/` is a catch-all name. + - Loc 6: the one-answer rule has one writer, but the path to that writer crosses two folders through ports. + - Shape 6: `sendResp()` and returning from the handler are two ways to answer. "Two `SmsSender`s in client mode" is left unresolved. + - Self 6: the glossary goes in the README, which the lessons say did not lift juniors. + - Overall 6. +3. **Where it still defeats me:** `client/client.ts`. It re-emits the current link's events, answers `boundAs` through the reconnect gap and owns the merger across links, while `client/next-link.ts` retries underneath it. This is F's `SmppClient` again. +4. **Wrong or vague:** + - Keeping the name `session` for a `ClientSession` is harmful. An app developer calls `session.sock` or `session.sendReturn()` and finds them moved to `session.link`. + - Which object owns the `SmsSender` is left open "until chunk 6", and that is the part most likely to rot. + - `sendResp()` plus answer-on-return breaks the one-spelling rule. + - Making `linkEnd` readonly is fine. + +## Plan 2: every piece of state has one owner, a private `Link` per socket behind the public `Session` + +1. **Would it help?** Marginal, leaning yes. The ownership discipline is the most honest of the three: a phase that only moves forward, one `AbortController` as the only stop signal, and `link/answers.ts` refusing a second answer. But `Session` stays the hub (current link, link wait, send window, reconnect, merger, handover). `Link` reports to it through a five-callback `LinkOwner`, which is the same shape E's panel called hard. The retry still spans links, in `session/outbound.ts` with four lanes. +2. **Predicted scores:** + - Nav 6: a `Session` versus `Link` vocabulary gap. Held messages and reassembly under `link/` surprise a reader who thinks of them as message concerns. + - Loc 6: a transition's meaning is split between `link.ts` and the `LinkOwner` callbacks implemented in `session.ts`. + - Shape 7: one writer per piece of state, and invariants stated at their owner. + - Self 6: `wire/fields.ts` names the bit masks, but the glossary sits in `docs/`, away from where the terms are used. + - Overall 6. +3. **Where it still defeats me:** `session/session.ts`, in `adopt()`/`onGone()` together with `session/outbound.ts`'s loop over `boundLink()`. That is the reconnect lifecycle kept inside the public unit, only renamed. +4. **Wrong or vague:** + - It keeps reconnect inside `Session`, which the lessons show is where the ceiling sits. The plan says the rename to `SmppClient` "bought nothing a reader scores", but F's gain was exactly the removal of the lifecycle from the list of hardest units. + - The four lanes stay four rules. + - Refusing the incoming segment when a store is full is a behaviour change under pressure, and the plan argues it only as a risk. + - The API changes are the most conservative: `sendResp()` becomes the handler's return value, `sendDlr()` before the answer returns `err`, and a double `sendReturn()` returns `err`. All are justified. + +## Plan 3: plain-English protocol types, answers as return values, `SmppClient` above a one-socket `Session` + +1. **Would it help?** Yes. It is the only plan that attacks all three standing caps at once: + - **Lifecycle:** one socket per `Session`, with reconnect in `SmppClient`. + - **Answering:** an answer is a `Reply` value, `requests-in.ts` is a pure function, and `session.write()` is the single writer. "Exactly one answer" becomes something the type system enforces. + - **Self-sufficiency:** `protocol/vocabulary.ts` gives definitions on hover, `data-coding.ts` becomes a table checked against today's code on all 256 values, and a test fails on any bare spec citation. Of the three, this is the one that actually changes a junior's Self-sufficiency. +2. **Predicted scores:** + - Nav 7: the folder order (`protocol` → `codec` → `messages` → `session` → `client`) tells you where a question is answered. + - Loc 6: `client/client.ts` gathers the current session, state kept across links, the retry loop, event re-emitting, `fromStart` and abort in one file. + - Shape 7: pure `replyFor()`, a store that refuses rather than evicts and never mutates on read, and a lifecycle that only moves forward. + - Self 7: vocabulary beside the code, and the citation rule enforced by a test. + - Overall 7, but only just. +3. **Where it still defeats me:** `client/client.ts` (`SmppClient`). The request loop waits for a bound session across reconnects, retries only what was never written, and re-emits events, all next to the `fromStart` and abort handling. The plan's own risk list predicts this file. `session/handlers.ts` converting a handler's outcome into a `Reply` for a message already answered on arrival comes second. +4. **Wrong or vague:** + - Six breaking changes where two carry the value. + - **A3** (`Reply.dlr`) is a second way to send a receipt beside `sendDlr()`. It is unjustified; drop it. + - **A6** (`'ASCII'` → `'GSM7'`) breaks every caller for a name that can be glossed once inside the library. Drop it. + - **A1** (`{ client }` in place of `{ session }`) is justified, and it is more honest than plan 1's `session` that is really a client. + - Refuse-not-evict means a peer that abandons segment groups blocks all new multipart traffic for `reassemblyTimeout`. The goal 4 regression is argued only in one direction. + - `retained.ts` and `bounded-store.ts` in `messages/` are misfiled, since neither is message logic. + - "Re-emits session events" does not say which events or how. + +## Verdict + +**Ranking:** Plan 3, then Plan 2, then Plan 1. + +**Does plan 3 reach 7?** Plausibly. My odds are about even, because Locality 6 is the cap and the overall may not exceed it by more than one. The single change that most raises the odds: move `SmppClient`'s bound-session wait and unwritten-only retry out of `client/client.ts` into a file of their own (plan 1's `client/next-link.ts` shape) with its `Invariant:` paragraph. `client.ts` is then only composition and state carried across links, and the unit the panel will name next is small and marked. Dropping A3 comes second. + +**Structure or intrinsic difficulty?** Structure, including the structure the public contract forced. Every unit the rounds named was one the code created, not the protocol: +- the held-message timing contract; +- `LinkLife`'s predicates and its duplicate stop flag; +- `ExpiringGroups` leaving its bounds to its callers; +- one answer enforced jointly by two files. + +The truly hard SMPP parts are few and can be kept in one place each: segments answered on arrival, the drain's two budgets, retrying only what never reached the socket, `data_coding` groups and operator receipt spellings. A 7 allows exactly that. The first two rounds failed because internals were rearranged under a contract that pinned the hard spot in place. The later rounds each removed a created unit and exposed the next one. Nothing yet shows that SMPP itself caps the scores at 6. + +PLAN1 helps=marginal nav=6 loc=6 shape=6 self=6 overall=6 +PLAN2 helps=marginal nav=6 loc=6 shape=7 self=6 overall=6 +PLAN3 helps=yes nav=7 loc=6 shape=7 self=7 overall=7 diff --git a/docs/comprehension-rewrite/drafts/draft-a.patch b/docs/comprehension-rewrite/drafts/draft-a.patch new file mode 100644 index 0000000..b8a0038 --- /dev/null +++ b/docs/comprehension-rewrite/drafts/draft-a.patch @@ -0,0 +1,2726 @@ +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(); +- private readonly route: HoldRoute; ++ private readonly offered = new WeakMap(); ++ private atBound = false; ++ private gone = false; + + constructor(options: HeldMessagesOptions) { + this.held = new ExpiringGroups({ +@@ -100,8 +102,10 @@ export class HeldMessages { + timeout: options.timeout, + }); + this.log = options.log; ++ this.max = options.max; + this.maxOctets = options.maxOctets; +- this.route = { link: options.link, sendPastDrain: options.sendPastDrain, session: options.session }; ++ this.sendPastDrain = options.sendPastDrain; ++ this.session = options.session; + } + + get octetsHeld(): number { +@@ -112,48 +116,64 @@ export class HeldMessages { + return this.held.size; + } + +- /** Whether a message arriving now is past the bound, once the expired are swept. */ +- full(): boolean { +- this.sweep(); +- +- return this.held.full || this.held.weight >= this.maxOctets; ++ /** The link these messages arrived on is gone. */ ++ isGone(): boolean { ++ return this.gone; + } + +- private hold(key: string, pduObjs: PduObject[], listeners: number): MessageHold { +- const hold = new MessageHold(this, this.route, pduObjs, listeners); +- ++ /** Whether a message arriving now is past the bound. Logs the crossing once, each way. */ ++ full(): boolean { + this.sweep(); + +- if (this.held.get(key)) { +- this.log.warn('heldMessages - replacing a message on a re-used sequence number', { seqNr: Number(key) }); ++ const full = this.held.full || this.held.weight >= this.maxOctets; ++ ++ if (full && !this.atBound) { ++ this.atBound = true; ++ this.log.warn('heldMessages - unanswered messages at their bound, refusing new ones until the application answers', { ++ messages: this.held.size, ++ octets: this.held.weight, ++ }); + } + +- 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 (!full && this.atBound && this.held.size <= this.max / 2 && this.held.weight <= this.maxOctets / 2) { ++ this.atBound = false; ++ this.log.info('heldMessages - unanswered messages down to half their bound, accepting again', { messages: this.held.size }); ++ } + +- return hold; ++ return full; + } + +- offer(pduObjs: PduObject[], answeredAs?: string): MessageHold | undefined { ++ /** Hands the message to the application. `answeredAs` is the id base its segments were already answered with. */ ++ offer(pduObjs: PduObject[], answeredAs?: string): HeldMessage | 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 hold = new HeldMessage(this, pduObjs, this.session.listenerCount('sms')); ++ const sms = createSms({ answeredAs, pduObjs, session: this.session }, hold); + ++ this.sweep(); ++ ++ if (this.held.get(key)) { ++ this.log.warn('heldMessages - replacing a message on a re-used sequence number', { seqNr: Number(key) }); ++ } ++ ++ this.held.set(key, pduObjs); ++ this.held.weigh(key, pduObjs.reduce((sum, pduObj) => sum + retainedOctets(pduObj), 0)); + this.offered.set(sms, hold); + +- if (!this.route.session.emit('sms', sms)) hold.release(); ++ // False: no listener, or one threw. That is not work a shutdown can wait for. ++ if (!this.session.emit('sms', sms)) hold.release(); + + return hold; + } + + /** 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; ++ rejected(sms: unknown): void { ++ if (typeof sms !== 'object' || sms === null) return; + +- this.offered.get(message)?.listenerGaveUp(); ++ this.offered.get(sms)?.listenerGaveUp(); + } + + holds(pduObjs: PduObject[]): boolean { +@@ -172,8 +192,10 @@ export class HeldMessages { + this.settle(); + } + +- /** Drops every message: their segments went with the link, so no answer of ours correlates now. */ ++ /** The link went: every message goes with it, since no answer of ours correlates now. */ + clear(): void { ++ this.gone = true; ++ this.atBound = false; + this.held.takeAll(); + this.idleWaiters.settle(); + } +@@ -183,7 +205,7 @@ 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. */ ++ /** Drops every message past its deadline. Runs before each offer and on its own timer. */ + sweep(): void { + const expired = this.held.takeExpired(); + +diff --git a/src/idle-waiters.ts b/src/idle-waiters.ts +index dd29a9e..1240687 100644 +--- a/src/idle-waiters.ts ++++ b/src/idle-waiters.ts +@@ -1,8 +1,3 @@ +-/** 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)[] = []; +diff --git a/src/incoming-requests.ts b/src/incoming-requests.ts +index 51aeec7..04cb6d6 100644 +--- a/src/incoming-requests.ts ++++ b/src/incoming-requests.ts +@@ -1,19 +1,16 @@ + 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 { LinkLife } from './link-life.ts'; ++import type { Link } from './link.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 { concatOf } from './concat.ts'; ++import { defaults } from './session-options.ts'; + import { detach } from './retained-pdu.ts'; + import { dlrFromPdu } from './dlr.ts'; + import { respIdParams, segmentId } from './sms-id.ts'; +@@ -43,15 +40,17 @@ const lostReasons: Record = { + linkGone: 'the link they arrived on went', + }; + ++/** A group given up on is traffic the peer will not send again, so it is reported as lost. */ ++export function lostGroupError(lost: LostGroup): Error { ++ return new Error( ++ `Gave up ${String(lost.parts)} of ${String(lost.total)} segments of an incomplete concatenated message: ${lostReasons[lost.reason]}`, ++ ); ++} ++ + export type IncomingRequestsOptions = { + dlrMerger: DlrMerger; +- 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; +@@ -60,51 +59,30 @@ export type IncomingRequestsOptions = { + /** Everything the peer asks of a session: messages, receipts, links and the answers to them. */ + export class IncomingRequests { + private readonly dlrMerger: DlrMerger; +- private readonly held: HeldMessages; +- private readonly link: LinkLife; + private readonly log: SmppLog; + private readonly onRequest: OnRequest | undefined; +- private readonly reassembler: Reassembler; + 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.link = options.link; + this.log = options.log; + this.onRequest = options.onRequest; +- this.reassembler = new Reassembler({ +- log: options.log, +- max: options.maxReassembly ?? defaults.maxReassembly, +- maxOctets: options.maxOctets, +- onLost: lost => { this.reportLost(lost); }, +- timeout: options.reassemblyTimeout ?? defaults.reassemblyTimeout, +- }); + this.session = options.session; + this.smsIdFormat = options.smsIdFormat ?? {}; + this.systemId = options.systemId ?? defaults.systemId; + } + +- async handle(pduObj: PduObject): Promise { +- const generation = this.link.generation(); ++ /** `link` is the one the request arrived on: what it holds is answered there, or not at all. */ ++ async handle(link: Link, pduObj: PduObject): Promise { + const { onRequest } = this; + + // Called unbound, so the application's hook never sees this class as its `this`. + if (onRequest && await onRequest(this.session, pduObj)) return; + + // The link it arrived on went while the hook ran, so nothing we answer now correlates. +- if (this.link.generation() !== generation) { ++ if (link.isClosed()) { + this.log.info('session - dropping a request whose link went', { cmdName: pduObj.cmdName }); + + return; +@@ -120,23 +98,23 @@ export class IncomingRequests { + return; + } + +- await this.route(pduObj); ++ await this.route(link, pduObj); + } + +- private async route(pduObj: PduObject): Promise { ++ private async route(link: Link, pduObj: PduObject): Promise { + switch (pduObj.cmdName) { + case 'data_sm': + case 'deliver_sm': + // A data_sm at the SMSC end is a submission, and a submission is never a report. + await (this.carriedAs(pduObj) === 'submit_sm' +- ? this.onMessage(pduObj) +- : this.onDelivery(pduObj)); ++ ? this.onMessage(link, pduObj) ++ : this.onDelivery(link, pduObj)); + break; + case 'enquire_link': + await this.session.sendReturn(pduObj); + break; + case 'submit_sm': +- await this.onMessage(pduObj); ++ await this.onMessage(link, pduObj); + break; + case 'unbind': + await this.session.sendReturn(pduObj); +@@ -148,28 +126,6 @@ export class IncomingRequests { + } + } + +- /** Drops the segments of every message that never became whole, and of every one still held. */ +- 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 { +- 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 { + if (bindCommands.includes(pduObj.cmdName)) { + this.log.info('session - bind on an already bound session', { cmdName: pduObj.cmdName }); +@@ -193,11 +149,11 @@ export class IncomingRequests { + } + + /** SMPP carries a mobile-originated message and a delivery receipt on the same command. */ +- private async onDelivery(pduObj: PduObject): Promise { ++ private async onDelivery(link: Link, pduObj: PduObject): Promise { + const dlr = dlrFromPdu(pduObj, this.smsIdFormat); + + if (!dlr) { +- await this.onMessage(pduObj); ++ await this.onMessage(link, pduObj); + + return; + } +@@ -211,54 +167,30 @@ export class IncomingRequests { + await this.session.sendReturn(pduObj); + } + +- private async refusedAtBound(pduObj: PduObject): Promise { +- 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, +- }); +- } +- ++ /** ++ * A concatenated message is answered segment by segment as it arrives: a peer that dispatches ++ * one request at a time never sends the second segment until the first has been answered. ++ */ ++ private async onMessage(link: Link, pduObj: PduObject): Promise { ++ if (link.held.full()) { + 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 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 }); ++ return; + } + +- return false; +- } +- +- /** +- * A concatenated message is answered segment by segment as it arrives: a peer that dispatches +- * one request at a time never sends the second segment until the first has been answered. +- */ +- private async onMessage(pduObj: PduObject): Promise { +- if (await this.refusedAtBound(pduObj)) return; +- + const concat = concatOf(pduObj); + + if (!concat) { +- this.held.offer([detach(pduObj)]); ++ link.held.offer([detach(pduObj)]); + + return; + } + +- const collected = this.reassembler.collect(pduObj, concat); ++ const collected = link.reassembler.collect(pduObj, concat); + + if (!collected.kept) { + await this.session.sendReturn( +@@ -275,12 +207,6 @@ export class IncomingRequests { + respIdParams(pduObj.cmdName, segmentId(collected.smsId, concat.part - 1, concat.total)), + ); + +- if (collected.whole) this.held.offer(collected.whole, collected.smsId); +- } +- +- private reportLost(lost: LostGroup): void { +- this.session.emit('sessionError', new Error( +- `Gave up ${String(lost.parts)} of ${String(lost.total)} segments of an incomplete concatenated message: ${lostReasons[lost.reason]}`, +- )); ++ if (collected.whole) link.held.offer(collected.whole, collected.smsId); + } + } +diff --git a/src/link-life.ts b/src/link-life.ts +deleted file mode 100644 +index f44f2ea..0000000 +--- a/src/link-life.ts ++++ /dev/null +@@ -1,188 +0,0 @@ +-import type { SmppLog } from './log.ts'; +-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(). */ +- 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'; +- +-type Waiter = (result: VoidResult) => void; +- +-function aborted(): Error { +- return new Error('Aborted while waiting for a link'); +-} +- +-function expired(): Error { +- return new Error('The link did not come back in time'); +-} +- +-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. */ +-export class LinkLife { +- private readonly log: SmppLog; +- private readonly now: () => number; +- private readonly reconnects: boolean; +- private readonly timeout: number; +- private readonly waiting = new Set(); +- private drops = 0; +- private phase: Phase = 'up'; +- private stopped = false; +- +- constructor(options: LinkLifeOptions) { +- this.log = options.log; +- this.now = options.now ?? Date.now; +- this.reconnects = options.reconnects; +- this.timeout = options.timeout; +- } +- +- /** A socket is on the link, bound or not. */ +- isAttached(): boolean { +- return this.phase === 'binding' || this.phase === 'up'; +- } +- +- /** Whether a request can go out right now. */ +- isUp(): boolean { +- return this.phase === 'up'; +- } +- +- private isOver(): boolean { +- return this.phase === 'ended'; +- } +- +- /** The session is shutting down: nothing new is taken, and no link follows this one. */ +- isStopped(): boolean { +- return this.stopped; +- } +- +- /** Whether a link that drops now is followed by another. */ +- retrying(): boolean { +- return this.reconnects && !this.stopped; +- } +- +- /** Not up and not over, with a link to come. */ +- awaitsNextLink(): boolean { +- return !this.isUp() && !this.isOver() && this.retrying(); +- } +- +- /** Changes with every drop, so what was read off one link can tell that link is gone. */ +- generation(): number { +- return this.drops; +- } +- +- /** 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(); +- } +- +- /** One budget for a request, however many links it waits through. */ +- hold(signal: AbortSignal | undefined): () => Promise { +- 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'; +- } +- +- /** The link is bound: everything held goes out on it. */ +- open(): void { +- this.phase = 'up'; +- +- if (this.waiting.size > 0) { +- this.log.verbose('linkLife - sending what was held for a link', { held: this.waiting.size }); +- } +- +- this.release({}); +- } +- +- /** 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; +- +- this.phase = 'down'; +- this.drops++; +- +- return this.retrying() ? 'disconnected' : 'close'; +- } +- +- stop(): void { +- this.stopped = true; +- } +- +- /** The session is over: nothing held will ever go out. False means it already was. */ +- end(): boolean { +- if (this.isOver()) return false; +- +- this.phase = 'ended'; +- this.stopped = true; +- this.release({ err: over() }); +- +- return true; +- } +- +- /** Resolves once a link can carry the request, or with the reason none ever will. */ +- private wait(deadline: number, signal: AbortSignal | undefined): Promise { +- if (this.isUp()) return Promise.resolve({}); +- +- const refused = this.refusal(); +- +- if (refused) return Promise.resolve({ err: refused }); +- +- if (signal?.aborted === true) return Promise.resolve({ err: aborted() }); +- +- const left = deadline === 0 ? 0 : deadline - this.now(); +- +- if (deadline !== 0 && left <= 0) return Promise.resolve({ err: expired() }); +- +- return this.waitForLink(left, signal); +- } +- +- private waitForLink(left: number, signal: AbortSignal | undefined): Promise { +- this.log.verbose('linkLife - holding a request until a link is back', { timeout: left }); +- +- return new Promise(resolve => { +- let timer: NodeJS.Timeout | undefined = undefined; +- const settle = (result: VoidResult): void => { +- if (timer) clearTimeout(timer); +- +- signal?.removeEventListener('abort', onAbort); +- this.waiting.delete(settle); +- resolve(result); +- }; +- const giveUp = (): void => { +- this.log.warn('linkLife - no link came back in time', { timeout: left }); +- settle({ err: expired() }); +- }; +- +- function onAbort(): void { +- settle({ err: aborted() }); +- } +- +- // Not unref()'d: a held request is awaited with no other handle, so the process would exit unsettled. +- if (left > 0) timer = setTimeout(giveUp, left); +- +- signal?.addEventListener('abort', onAbort, { once: true }); +- this.waiting.add(settle); +- }); +- } +- +- private release(result: VoidResult): void { +- for (const settle of [...this.waiting]) { +- settle(result); +- } +- } +-} +diff --git a/src/link-timers.ts b/src/link-timers.ts +deleted file mode 100644 +index cd6710a..0000000 +--- a/src/link-timers.ts ++++ /dev/null +@@ -1,50 +0,0 @@ +-import type { SmppLog } from './log.ts'; +- +-export type LinkTimersOptions = { +- /** How long between enquire_link probes. Undefined or 0 never probes. */ +- enquireLinkInterval?: number | undefined; +- /** How long a silent peer is kept. Undefined or 0 keeps it forever. */ +- idleTimeout?: number | undefined; +- log: SmppLog; +- onEnquireLink: () => void; +- onIdle: () => void; +-}; +- +-/** Keeps a quiet connection honest: probes the peer, and gives up on one that stays silent. */ +-export class LinkTimers { +- private readonly options: LinkTimersOptions; +- private enquireLink: NodeJS.Timeout | undefined; +- private idle: NodeJS.Timeout | undefined; +- +- constructor(options: LinkTimersOptions) { +- this.options = options; +- } +- +- /** Starts both timers over, which every sign of life from the peer should do. */ +- reset(): void { +- const { enquireLinkInterval, idleTimeout, log, onEnquireLink, onIdle } = this.options; +- +- this.clear(); +- +- if (enquireLinkInterval !== undefined && enquireLinkInterval > 0) { +- this.enquireLink = setTimeout(onEnquireLink, enquireLinkInterval); +- this.enquireLink.unref(); +- } +- +- if (idleTimeout !== undefined && idleTimeout > 0) { +- this.idle = setTimeout(() => { +- log.info('linkTimers - closing an idle peer', { idleTimeout }); +- onIdle(); +- }, idleTimeout); +- this.idle.unref(); +- } +- } +- +- clear(): void { +- if (this.enquireLink) clearTimeout(this.enquireLink); +- if (this.idle) clearTimeout(this.idle); +- +- this.enquireLink = undefined; +- this.idle = undefined; +- } +-} +diff --git a/src/link.ts b/src/link.ts +new file mode 100644 +index 0000000..efd05b1 +--- /dev/null ++++ b/src/link.ts +@@ -0,0 +1,243 @@ ++import type { HeldMessagesOptions } from './held-messages.ts'; ++import type { PduObject, PduObjectInput } from './pdu.ts'; ++import type { ReassemblerOptions } from './reassembly.ts'; ++import type { Result, VoidResult } from './result.ts'; ++import type { SendOptions } from './session-options.ts'; ++import type { SmppLog } from './log.ts'; ++import type { Socket } from 'node:net'; ++import { HeldMessages } from './held-messages.ts'; ++import { PduFramer } from './pdu-framer.ts'; ++import { PduRefusedError } from './pdu-refusal.ts'; ++import { PendingRequests } from './pending-requests.ts'; ++import { Reassembler } from './reassembly.ts'; ++import { UnansweredError } from './unanswered-error.ts'; ++import { isResp, objToPdu, pduToObj } from './pdu.ts'; ++ ++export type LinkEvents = { ++ /** Raw bytes, before framing. */ ++ data: (chunk: Buffer) => void; ++ enquireLink: () => void; ++ /** A complete PDU, before it is parsed. */ ++ framed: (pdu: Buffer) => void; ++ /** Nothing further will be read off the socket: it closed, errored, fell silent or lost sync. */ ++ lost: (link: Link, err?: Error) => void; ++ /** A framed PDU the codec could not read. The stream is still in sync, so the link is not lost. */ ++ refused: (refused: PduRefusedError) => void; ++ /** A request the peer sent. Responses never reach here: they settle the request waiting on this link. */ ++ request: (link: Link, pduObj: PduObject) => void; ++}; ++ ++export type LinkOptions = { ++ /** Whether requests may go out from the start. A reconnect's socket carries only its bind until `markBound()`. */ ++ bound: boolean; ++ /** Undefined or 0 never probes. */ ++ enquireLinkInterval?: number | undefined; ++ held: Omit; ++ /** Undefined or 0 keeps a silent peer forever. */ ++ idleTimeout?: number | undefined; ++ log: SmppLog; ++ on: LinkEvents; ++ reassembly: Omit; ++ responseTimeout: number; ++ sock: Socket; ++}; ++ ++/** `retryOnNextLink`: the write failed, so nothing reached the socket and another link may carry it. */ ++export type Attempt = { result: Result<{ pduObj: PduObject }>; retryOnNextLink: boolean }; ++ ++function abortedBeforeSend(): Error { ++ return new Error('Aborted before the request was sent'); ++} ++ ++/** ++ * One socket: the PDUs read off it, the timers that keep it honest, the requests waiting on it and ++ * the messages that arrived on it. A reconnect opens a new one; `close()` ends this one for good. ++ */ ++export class Link { ++ readonly held: HeldMessages; ++ readonly pending: PendingRequests; ++ readonly reassembler: Reassembler; ++ readonly sock: Socket; ++ ++ private readonly framer = new PduFramer(); ++ private readonly log: SmppLog; ++ private readonly options: LinkOptions; ++ private bound: boolean; ++ private closed = false; ++ private enquireLinkTimer: NodeJS.Timeout | undefined; ++ private idleTimer: NodeJS.Timeout | undefined; ++ ++ constructor(options: LinkOptions) { ++ this.bound = options.bound; ++ this.held = new HeldMessages({ ...options.held, log: options.log }); ++ this.log = options.log; ++ this.options = options; ++ this.pending = new PendingRequests(options.log); ++ this.reassembler = new Reassembler({ ...options.reassembly, log: options.log }); ++ this.sock = options.sock; ++ ++ options.sock.on('data', chunk => { this.read(chunk); }); ++ options.sock.on('close', () => { options.on.lost(this); }); ++ options.sock.on('error', err => { ++ this.log.warn('link - socket error', { message: err.message }); ++ options.on.lost(this, err); ++ }); ++ ++ if (this.bound) this.resetTimers(); ++ } ++ ++ /** Whether a request can go out on it right now. */ ++ canCarry(): boolean { ++ return this.bound && !this.closed && !this.sock.destroyed; ++ } ++ ++ isClosed(): boolean { ++ return this.closed; ++ } ++ ++ /** The bind was answered, so requests may go out on it. */ ++ markBound(): void { ++ this.bound = true; ++ this.resetTimers(); ++ } ++ ++ write(pdu: Buffer): VoidResult { ++ if (this.sock.destroyed) return { err: new Error('Socket is closed') }; ++ ++ this.sock.write(pdu); ++ ++ return {}; ++ } ++ ++ /** Puts a request on the wire and resolves with the peer's response. */ ++ async send(input: PduObjectInput, options: SendOptions): Promise { ++ // pending.wait() alone settles the caller while the request still goes out to the peer. ++ if (options.signal?.aborted === true) { ++ return { result: { err: abortedBeforeSend() }, retryOnNextLink: false }; ++ } ++ ++ const seqNr = this.pending.nextSeqNr(); ++ const built = objToPdu({ ...input, seqNr }); ++ ++ if (built.err) return { result: { err: built.err }, retryOnNextLink: false }; ++ ++ const response = this.pending.wait(seqNr, { ++ signal: options.signal, ++ timeout: this.options.responseTimeout, ++ }); ++ const written = this.write(built.buffer); ++ ++ if (written.err) { ++ this.pending.settle(seqNr, { err: written.err }); ++ ++ return { result: { err: written.err }, retryOnNextLink: true }; ++ } ++ ++ const answered = await response; ++ ++ // It went out, so a failure now means the peer may have taken it and the answer was the loss. ++ return { result: answered.err ? { err: new UnansweredError(answered.err) } : answered, retryOnNextLink: false }; ++ } ++ ++ /** ++ * Once: fails every request waiting on it, drops every message that arrived on it, and destroys ++ * the socket. Nothing is read off it after this, so the socket's own close event changes nothing. ++ */ ++ close(): void { ++ if (this.closed) return; ++ ++ this.closed = true; ++ this.pending.settleAll(new Error('Session closed before a response arrived')); ++ this.clearTimers(); ++ this.held.clear(); ++ this.reassembler.clear(); ++ this.sock.destroy(); ++ } ++ ++ private read(chunk: Buffer): void { ++ this.options.on.data(chunk); ++ this.resetTimers(); ++ this.framer.push(chunk); ++ ++ const framed = this.framer.next(); ++ ++ if (framed.err) { ++ this.log.warn('link - unusable stream', { message: framed.err.message }); ++ this.options.on.lost(this, framed.err); ++ ++ return; ++ } ++ ++ for (const pdu of framed.pdus) { ++ this.options.on.framed(pdu); ++ ++ const parsed = pduToObj(pdu); ++ ++ if (parsed.err) { ++ this.refuse(parsed.err); ++ ++ continue; ++ } ++ ++ this.dispatch(parsed.pduObj); ++ } ++ } ++ ++ private dispatch(pduObj: PduObject): void { ++ if (!isResp(pduObj)) { ++ this.options.on.request(this, pduObj); ++ ++ return; ++ } ++ ++ if (!this.pending.deliver(pduObj)) { ++ this.log.debug('link - response with no matching request', { seqNr: pduObj.seqNr }); ++ } ++ } ++ ++ private refuse(err: Error): void { ++ if (!(err instanceof PduRefusedError)) { ++ // The framer applies framingRefusal() first, so only a caller that skips it lands here. ++ this.log.warn('link - could not parse an incoming PDU', { message: err.message }); ++ this.options.on.lost(this, err); ++ ++ return; ++ } ++ ++ this.log.warn('link - refusing a PDU it could not read', { message: err.message, reason: err.reason }); ++ this.options.on.refused(err); ++ ++ // A response carries a sequence number of ours, so it settles the request instead of being answered. ++ if (isResp(err.header)) this.pending.settle(err.header.seqNr, { err }); ++ } ++ ++ /** Starts both timers over, which every sign of life from the peer does. */ ++ private resetTimers(): void { ++ const { enquireLinkInterval, idleTimeout, on } = this.options; ++ ++ this.clearTimers(); ++ ++ if (this.closed) return; ++ ++ if (enquireLinkInterval !== undefined && enquireLinkInterval > 0) { ++ this.enquireLinkTimer = setTimeout(on.enquireLink, enquireLinkInterval); ++ this.enquireLinkTimer.unref(); ++ } ++ ++ if (idleTimeout !== undefined && idleTimeout > 0) { ++ this.idleTimer = setTimeout(() => { ++ this.log.info('link - closing an idle peer', { idleTimeout }); ++ on.lost(this); ++ }, idleTimeout); ++ this.idleTimer.unref(); ++ } ++ } ++ ++ private clearTimers(): void { ++ if (this.enquireLinkTimer) clearTimeout(this.enquireLinkTimer); ++ if (this.idleTimer) clearTimeout(this.idleTimer); ++ ++ this.enquireLinkTimer = undefined; ++ this.idleTimer = undefined; ++ } ++} +diff --git a/src/outgoing-requests.ts b/src/outgoing-requests.ts +index a0adf24..664b53b 100644 +--- a/src/outgoing-requests.ts ++++ b/src/outgoing-requests.ts +@@ -1,30 +1,48 @@ +-import type { LinkLife } from './link-life.ts'; ++import type { Attempt, Link } from './link.ts'; + import type { PduObject, PduObjectInput } from './pdu.ts'; +-import type { PduTransport } from './pdu-transport.ts'; + import type { Result, VoidResult } from './result.ts'; + import type { SendOptions } from './session-options.ts'; + 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 { objToPdu } from './pdu.ts'; ++import { bindCommands } from './bind-direction.ts'; ++ ++/** What the senders read of the session's life. The session owns it and answers each from its state. */ ++export type LinkView = { ++ /** A shutdown has begun: no new request is taken, and no link follows the current one. */ ++ closing: () => boolean; ++ /** The latest link, closed or not. */ ++ current: () => Link; ++ /** Whether a link that is gone is followed by another. */ ++ nextExpected: () => boolean; ++}; + + export type OutgoingRequestsOptions = { +- link: LinkLife; ++ links: LinkView; + log: SmppLog; + maxOutstanding: number; ++ now?: (() => number) | undefined; ++ /** How long a request may wait for a link. 0 waits for as long as one may still arrive. */ + responseTimeout: number; +- transport: PduTransport; + }; + +-/** `retryOnNextLink`: the write failed, so nothing reached the socket and another link may carry it. */ +-type Attempt = { result: Result<{ pduObj: PduObject }>; retryOnNextLink: boolean }; ++type Waiter = (result: VoidResult) => void; + + function abortedBeforeSend(): Error { + return new Error('Aborted before the request was sent'); + } + ++function abortedWaiting(): Error { ++ return new Error('Aborted while waiting for a link'); ++} ++ ++function expired(): Error { ++ return new Error('The link did not come back in time'); ++} ++ ++function over(): Error { ++ return new Error('Session is closed'); ++} ++ + /** A response carries the request's sequence number, which only sendReturn() has. */ + function misuse(input: PduObjectInput): Error | undefined { + return input.cmdName.endsWith('_resp') +@@ -32,43 +50,28 @@ function misuse(input: PduObjectInput): Error | undefined { + : undefined; + } + ++/** Why a request cannot go out at all. Before the link and the window, or an aborted call waits for what it will never use. */ ++function refusal(input: PduObjectInput, options: SendOptions): Error | undefined { ++ return misuse(input) ?? (options.signal?.aborted === true ? abortedBeforeSend() : 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 links: LinkView; + private readonly log: SmppLog; +- private readonly pending: PendingRequests; ++ private readonly now: () => number; + private readonly responseTimeout: number; +- private readonly transport: PduTransport; ++ private readonly waiting = new Set(); + private readonly window: SendWindow; + + constructor(options: OutgoingRequestsOptions) { +- this.link = options.link; ++ this.links = options.links; + this.log = options.log; +- this.pending = new PendingRequests(options.log); ++ this.now = options.now ?? Date.now; + this.responseTimeout = options.responseTimeout; +- this.transport = options.transport; + this.window = new SendWindow({ limit: options.maxOutstanding, log: options.log }); + } + +- canCarry(): boolean { +- return this.link.isUp() && !this.transport.sock.destroyed; +- } +- +- /** The link is gone, and every answer still owed on it with it. */ +- linkLost(): void { +- this.pending.settleAll(new Error('Session closed before a response arrived')); +- } +- +- /** Hands a response to the request waiting for it. False means nothing was. */ +- deliver(pduObj: PduObject): boolean { +- return this.pending.deliver(pduObj); +- } +- +- /** A response the codec refused settles its request instead of leaving it to time out. */ +- settleRefused(seqNr: number, err: Error): void { +- this.pending.settle(seqNr, { err }); +- } +- + request(input: PduObjectInput, options: SendOptions): Promise> { + // Ahead of the drain, so a misuse is named as one rather than blamed on the shutdown. + const wrong = misuse(input); +@@ -76,44 +79,54 @@ 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.links.closing() && this.links.current().canCarry()) { + return Promise.resolve({ err: new Error('Session is shutting down') }); + } + +- return this.requestPastDrain(input, options); ++ return this.requestDuringDrain(input, options); + } + + /** request() without the drain's refusal, which a receipt for a held message has to take. */ +- async requestPastDrain( ++ async requestDuringDrain( + input: PduObjectInput, + options: SendOptions, + ): Promise> { +- const refused = this.refuse(input, options); ++ const refused = refusal(input, options); + + if (refused) return { 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(); ++ if (bindCommands.includes(input.cmdName)) return this.bindOnCurrentLink(input, options); + +- return shut ? { err: shut } : this.requestOnCurrentLink(input, options); +- } +- +- const waitForLink = this.link.hold(options.signal); ++ const deadline = this.responseTimeout > 0 ? this.now() + this.responseTimeout : 0; + + for (;;) { +- const held = await waitForLink(); ++ const carrier = await this.carrier(deadline, options.signal); + +- if (held.err) return { err: held.err }; ++ if (carrier.err) return { err: carrier.err }; + +- const slot = await this.window.acquire(options.signal); ++ const attempt = await this.attemptOn(carrier.link, input, options); + +- if (slot.err) return { err: slot.err }; ++ // Nothing reached the socket, so the next link carries it; with none to come, this is the answer. ++ if (!attempt.retryOnNextLink || !this.links.nextExpected()) return attempt.result; ++ } ++ } + +- const attempt = await this.attempt(input, options).finally(() => { this.window.release(); }); ++ private async bindOnCurrentLink(input: PduObjectInput, options: SendOptions): Promise> { ++ const link = this.links.current(); + +- if (!this.retriesOnNextLink(attempt)) return attempt.result; +- } ++ if (!link.canCarry() && !this.links.nextExpected()) return { err: over() }; ++ ++ return (await link.send(input, options)).result; ++ } ++ ++ /** One try on one link, under a send-window slot. */ ++ private async attemptOn(link: Link, input: PduObjectInput, options: SendOptions): Promise { ++ const slot = await this.window.acquire(options.signal); ++ ++ if (slot.err) return { result: { err: slot.err }, retryOnNextLink: false }; ++ ++ return link.send(input, options).finally(() => { this.window.release(); }); + } + + /** Straight onto the current link, for what has to go out either way. */ +@@ -121,58 +134,81 @@ export class OutgoingRequests { + input: PduObjectInput, + options: SendOptions = {}, + ): Promise> { +- return (await this.attempt(input, options)).result; ++ return (await this.links.current().send(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 { +- const unfinished = await this.window.idle(timeout, signal); +- +- if (unfinished === 0) return {}; ++ /** Resolves 0 once nothing is on the wire or queued for it, or with what still is. */ ++ idle(timeout: number, signal: AbortSignal | undefined): Promise { ++ return this.window.idle(timeout, signal); ++ } + +- this.log.warn('outgoingRequests - shutting down with requests unfinished', { timeout, unfinished }); ++ /** A link is bound: everything held for one goes out on it. */ ++ linkBound(): void { ++ if (this.waiting.size > 0) { ++ this.log.verbose('outgoingRequests - sending what was held for a link', { held: this.waiting.size }); ++ } + +- return { err: new Error(`Shut down with ${String(unfinished)} request(s) unfinished`) }; ++ this.release({}); + } + +- /** 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(); ++ /** No link will follow, so nothing held for one will ever go out. */ ++ over(): void { ++ this.release({ err: over() }); + } + +- /** Why a request cannot go out at all, as opposed to not yet. */ +- private refuse(input: PduObjectInput, options: SendOptions): Error | undefined { +- // Before the link and the window, or an aborted call waits for what it will never use. +- return misuse(input) ?? (options.signal?.aborted === true ? abortedBeforeSend() : undefined); +- } ++ /** Resolves with a link that can carry the request, or with the reason none ever will. */ ++ private async carrier(deadline: number, signal: AbortSignal | undefined): Promise> { ++ for (;;) { ++ const link = this.links.current(); + +- private async attempt(input: PduObjectInput, options: SendOptions): Promise { +- // pending.wait() alone settles the caller while the request still goes out to the peer. +- if (options.signal?.aborted === true) { +- return { result: { err: abortedBeforeSend() }, retryOnNextLink: false }; +- } ++ if (link.canCarry()) return { link }; + +- const seqNr = this.pending.nextSeqNr(); +- const built = objToPdu({ ...input, seqNr }); ++ if (!this.links.nextExpected()) return { err: over() }; + +- if (built.err) return { result: { err: built.err }, retryOnNextLink: false }; ++ if (signal?.aborted === true) return { err: abortedWaiting() }; + +- const response = this.pending.wait(seqNr, { +- signal: options.signal, +- timeout: this.responseTimeout, +- }); +- const written = this.transport.write(built.buffer); ++ const left = deadline === 0 ? 0 : deadline - this.now(); ++ ++ if (deadline !== 0 && left <= 0) return { err: expired() }; + +- if (written.err) { +- this.pending.settle(seqNr, { err: written.err }); ++ const waited = await this.waitForLink(left, signal); + +- return { result: { err: written.err }, retryOnNextLink: true }; ++ if (waited.err) return { err: waited.err }; + } ++ } ++ ++ private waitForLink(left: number, signal: AbortSignal | undefined): Promise { ++ this.log.verbose('outgoingRequests - holding a request until a link is back', { timeout: left }); ++ ++ return new Promise(resolve => { ++ let timer: NodeJS.Timeout | undefined = undefined; ++ const settle = (result: VoidResult): void => { ++ if (timer) clearTimeout(timer); ++ ++ signal?.removeEventListener('abort', onAbort); ++ this.waiting.delete(settle); ++ resolve(result); ++ }; ++ const giveUp = (): void => { ++ this.log.warn('outgoingRequests - no link came back in time', { timeout: left }); ++ settle({ err: expired() }); ++ }; + +- const answered = await response; ++ function onAbort(): void { ++ settle({ err: abortedWaiting() }); ++ } + +- // It went out, so a failure now means the peer may have taken it and the answer was the loss. +- return { result: answered.err ? { err: new UnansweredError(answered.err) } : answered, retryOnNextLink: false }; ++ // Not unref()'d: a held request is awaited with no other handle, so the process would exit unsettled. ++ if (left > 0) timer = setTimeout(giveUp, left); ++ ++ signal?.addEventListener('abort', onAbort, { once: true }); ++ this.waiting.add(settle); ++ }); ++ } ++ ++ private release(result: VoidResult): void { ++ for (const settle of [...this.waiting]) { ++ settle(result); ++ } + } + } +diff --git a/src/pdu-transport.ts b/src/pdu-transport.ts +deleted file mode 100644 +index 966cdee..0000000 +--- a/src/pdu-transport.ts ++++ /dev/null +@@ -1,108 +0,0 @@ +-import type { PduObject } from './pdu.ts'; +-import type { SmppLog } from './log.ts'; +-import type { Socket } from 'node:net'; +-import type { VoidResult } from './result.ts'; +-import { PduFramer } from './pdu-framer.ts'; +-import { PduRefusedError } from './pdu-refusal.ts'; +-import { pduToObj } from './pdu.ts'; +- +-export type PduTransportOptions = { +- log: SmppLog; +- onClose: () => void; +- /** Raw bytes, before framing. */ +- onData: (chunk: Buffer) => void; +- onError: (err: Error) => void; +- /** A complete PDU, before it is parsed. */ +- onFramed: (pdu: Buffer) => void; +- onPdu: (pduObj: PduObject) => void; +- /** A framed PDU the codec could not read. The stream is still in sync, so the link is not lost. */ +- onRefused: (refused: PduRefusedError) => void; +- /** Nothing further can be read off this stream, whatever the socket does next. */ +- onUnreadable: (err: Error) => void; +-}; +- +-/** A socket read as a stream of complete PDUs. A reconnect attaches a new socket in its place. */ +-export class PduTransport { +- private readonly options: PduTransportOptions; +- private framer = new PduFramer(); +- private socket: Socket; +- +- constructor(options: PduTransportOptions, sock: Socket) { +- this.options = options; +- this.socket = sock; +- this.wire(sock); +- } +- +- get sock(): Socket { +- return this.socket; +- } +- +- /** Takes over a freshly opened socket. Half a PDU left on the old one must not prefix this one. */ +- attach(sock: Socket): void { +- // The socket being replaced is already dead, and its three handlers still point here. +- this.socket.removeAllListeners(); +- this.socket = sock; +- this.framer = new PduFramer(); +- this.wire(sock); +- } +- +- private wire(sock: Socket): void { +- sock.on('data', chunk => { this.read(chunk); }); +- sock.on('close', () => { this.options.onClose(); }); +- sock.on('error', err => { +- this.options.log.warn('transport - socket error', { message: err.message }); +- this.options.onError(err); +- this.options.onClose(); +- }); +- } +- +- write(pdu: Buffer): VoidResult { +- if (this.socket.destroyed) return { err: new Error('Socket is closed') }; +- +- this.socket.write(pdu); +- +- return {}; +- } +- +- private read(chunk: Buffer): void { +- this.options.onData(chunk); +- this.framer.push(chunk); +- +- const framed = this.framer.next(); +- +- if (framed.err) { +- this.options.log.warn('transport - unusable stream', { message: framed.err.message }); +- this.options.onUnreadable(framed.err); +- +- return; +- } +- +- for (const pdu of framed.pdus) { +- this.options.onFramed(pdu); +- +- const parsed = pduToObj(pdu); +- +- if (parsed.err instanceof PduRefusedError) { +- this.options.log.warn('transport - refusing a PDU it could not read', { +- message: parsed.err.message, +- reason: parsed.err.reason, +- }); +- this.options.onRefused(parsed.err); +- +- continue; +- } +- +- // The framer applies framingRefusal() first, so only a caller that skips it lands here. +- if (parsed.err) { +- this.options.log.warn('transport - could not parse an incoming PDU', { +- message: parsed.err.message, +- }); +- this.options.onUnreadable(parsed.err); +- +- return; +- } +- +- this.options.onPdu(parsed.pduObj); +- } +- } +-} +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..86e303b 100644 +--- a/src/session.ts ++++ b/src/session.ts +@@ -1,25 +1,25 @@ ++import type { BindType, LinkEnd, SessionBind } from './bind-direction.ts'; + import type { ErrorName } from './defs/errors.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 { IncomingRequests } from './incoming-requests.ts'; +-import { LinkLife } from './link-life.ts'; +-import { LinkTimers } from './link-timers.ts'; ++import { IncomingRequests, lostGroupError } from './incoming-requests.ts'; ++import { Link } from './link.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'; +@@ -42,6 +42,9 @@ export { bindCommands, defaultSystemId }; + /** A listener may return a promise: an `async` one that rejects is routed like one that throws. */ + type SessionListener = (...args: SessionEvents[K]) => unknown; + ++/** `closing`: a shutdown began; new sends are refused and no link follows the current one. */ ++type Life = 'closed' | 'closing' | 'open'; ++ + export class Session extends EventEmitter { + declare addListener: (event: K, listener: SessionListener) => this; + declare off: (event: K, listener: SessionListener) => this; +@@ -58,16 +61,16 @@ export class Session extends EventEmitter { + userData: unknown = undefined; + + private bind: SessionBind | undefined = undefined; ++ private life: Life = 'open'; ++ /** The latest socket. Between links it is the closed one, so `sock` still answers. */ ++ private link: Link; + + private readonly concatReference = new ConcatReference(); + private readonly dlrMerger: DlrMerger; + private readonly incoming: IncomingRequests; +- private readonly link: LinkLife; + private readonly options: SessionOptions; + private readonly outgoing: OutgoingRequests; + private readonly reconnectLoop: ReconnectLoop | undefined; +- private readonly timers: LinkTimers; +- private readonly transport: PduTransport; + + /** A listener that throws is the application's bug; it must not become ours. Hard rule 1. */ + override emit( +@@ -98,7 +101,7 @@ export class Session extends EventEmitter { + + this.log.error('session - a listener rejected', { event, message: error.message }); + +- if (event === 'sms') this.incoming.listenerRejected(rest[0]); ++ if (event === 'sms') this.link.held.rejected(rest[0]); + + if (event !== 'sessionError') this.emit('sessionError', error); + } +@@ -110,46 +113,31 @@ export class Session extends EventEmitter { + this.options = options; + this.dlrMerger = new DlrMerger({ log: this.log, max: defaults.maxDlrMerges, timeout: defaults.dlrMergeTimeout }); + this.reconnectLoop = this.loopFor(options.reconnect); +- +- const responseTimeout = options.responseTimeout ?? defaults.responseTimeout; +- +- this.link = new LinkLife({ log: this.log, reconnects: this.reconnectLoop !== undefined, timeout: responseTimeout }); +- this.timers = new LinkTimers({ +- enquireLinkInterval: options.enquireLinkInterval, +- 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(); }, +- }); +- this.transport = this.transportFor(options.sock); + this.outgoing = new OutgoingRequests({ +- link: this.link, ++ links: { ++ closing: () => this.life !== 'open', ++ current: () => this.link, ++ nextExpected: () => this.nextLinkExpected(), ++ }, + log: this.log, + maxOutstanding: options.maxOutstanding ?? defaults.maxOutstanding, +- responseTimeout, +- transport: this.transport, ++ responseTimeout: this.responseTimeout(), + }); + this.incoming = new IncomingRequests({ + dlrMerger: this.dlrMerger, +- 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(); ++ // The first socket carries from the start: its bind goes out through send(). ++ this.link = this.openLink(options.sock, true); + } + + /** Replaced on reconnect, so hold the session rather than this. */ + get sock(): Socket { +- return this.transport.sock; ++ return this.link.sock; + } + + /** The role the ESME bound with, whichever end of the link this is. Undefined before any bind. */ +@@ -196,22 +184,6 @@ export class Session extends EventEmitter { + 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 { + if (!this.bindAllows('submit_sm')) { + return unsent(new Error('A receiver-bound session does not carry submit_sm')); +@@ -235,11 +207,12 @@ export class Session extends EventEmitter { + */ + async unbind(): Promise { + const drained = await this.drain(undefined); +- const wasOpen = this.link.isAttached(); ++ const link = this.link; ++ const wasOpen = !link.isClosed(); + const sent = wasOpen + ? await this.outgoing.requestOnCurrentLink({ cmdName: 'unbind' }) + : { err: new Error('Session is closed') }; +- const closedOnUnbind = wasOpen && !this.link.isAttached(); ++ const closedOnUnbind = wasOpen && link.isClosed(); + + this.end(); + +@@ -258,155 +231,165 @@ export class Session extends EventEmitter { + return drained; + } + +- private transportFor(sock: Socket): PduTransport { +- return new PduTransport({ +- log: this.log, +- onClose: () => { this.onClose(); }, +- onData: chunk => { this.onData(chunk); }, +- onError: err => { this.emit('sessionError', err); }, +- onFramed: pdu => { this.emit('incomingPdu', pdu); }, +- onPdu: pduObj => { this.dispatch(pduObj); }, +- onRefused: refused => { this.refuse(refused); }, +- onUnreadable: err => { +- this.emit('sessionError', err); +- this.teardown(); +- }, +- }, sock); ++ private answer(built: Result<{ buffer: Buffer }>, cmdName: string, seqNr: number): VoidResult { ++ const sent = built.err ? { err: built.err } : this.link.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.isClosed()) { ++ this.log.warn('session - could not answer a request', { ++ cmdName, ++ message: sent.err.message, ++ seqNr, ++ }); ++ this.emit('sessionError', sent.err); ++ } ++ ++ return sent; + } + +- private loopFor(reconnect: ReconnectOptions | undefined): ReconnectLoop | undefined { +- if (!reconnect) return undefined; ++ /** Stops new sends and the reconnect loop, then waits out what the peer is owed on the link. */ ++ private async drain(signal: AbortSignal | undefined): Promise { ++ if (this.life === 'open') this.life = 'closing'; + +- return new ReconnectLoop({ +- connect: reconnect.connect, ++ this.reconnectLoop?.stop(); ++ ++ const link = this.link; ++ ++ // No bound link, so nothing is on the wire to wait out. ++ if (!link.canCarry()) return {}; ++ ++ const drained = await drain({ ++ messages: (timeout, cut) => link.held.idle(timeout, cut), ++ requests: (timeout, cut) => this.outgoing.idle(timeout, cut), ++ }, { ++ responseTimeout: this.responseTimeout(), ++ shutdownTimeout: this.options.shutdownTimeout ?? defaults.shutdownTimeout, ++ }, signal, this.log); ++ ++ // The link went before the drain finished, so an empty window says nothing about the peer. ++ if (!link.canCarry()) return { err: new Error('The session closed before the drain finished') }; ++ ++ return drained; ++ } ++ ++ // The session's life. A link opens, binds and is lost; the session ends once. Every event about ++ // the life is emitted from one of these four, and nothing else changes `life` or `link`. ++ ++ private openLink(sock: Socket, bound: boolean): Link { ++ return new Link({ ++ bound, ++ enquireLinkInterval: this.options.enquireLinkInterval, ++ held: { ++ max: defaults.maxHeldMessages, ++ maxOctets: defaults.maxHeldOctets, ++ sendPastDrain: input => this.outgoing.requestDuringDrain(input, {}), ++ session: this, ++ timeout: defaults.heldMessageTimeout, ++ }, ++ idleTimeout: this.options.idleTimeout, + log: this.log, +- maxDelay: reconnect.maxDelay, +- minDelay: reconnect.minDelay, +- onConnected: sock => this.comeBackUp(sock, reconnect.onConnected), ++ on: { ++ data: chunk => { this.emit('data', chunk); }, ++ enquireLink: () => { void this.send({ cmdName: 'enquire_link' }); }, ++ framed: pdu => { this.emit('incomingPdu', pdu); }, ++ lost: (link, err) => { this.linkLost(link, err); }, ++ refused: refused => { this.refuse(refused); }, ++ request: (link, pduObj) => { this.dispatch(link, pduObj); }, ++ }, ++ reassembly: { ++ max: this.options.maxReassembly ?? defaults.maxReassembly, ++ maxOctets: this.options.maxOctets, ++ onLost: lost => { this.emit('sessionError', lostGroupError(lost)); }, ++ timeout: this.options.reassemblyTimeout ?? defaults.reassemblyTimeout, ++ }, ++ responseTimeout: this.responseTimeout(), ++ sock, + }); + } + +- private async comeBackUp( +- sock: Socket, +- bind: (session: Session) => Promise, +- ): Promise { +- this.attach(sock); ++ /** Brings the session up on the loop's fresh socket. An err means the loop tries again. */ ++ private async comeBackUp(sock: Socket, bind: (session: Session) => Promise): Promise { ++ const link = this.openLink(sock, false); ++ ++ this.link = link; + + const bound = await bind(this); + + if (bound.err) { +- this.teardown(); ++ this.linkLost(link); + + 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') }; +- } ++ if (link.isClosed()) return { err: new Error('Session closed while it was coming back up') }; + +- this.resetTimers(); +- this.link.open(); ++ link.markBound(); ++ this.outgoing.linkBound(); + 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 { +- this.stop(); ++ /** The one exit for a socket: it closed, errored, fell silent, lost sync or failed to rebind. */ ++ private linkLost(link: Link, err?: Error): void { ++ if (link !== this.link || link.isClosed()) return; + +- // No bound link, so nothing is on the wire to wait out. +- if (!this.outgoing.canCarry()) return {}; ++ if (err) this.emit('sessionError', err); + +- 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') }; +- } ++ // Read before close(): a listener it reaches may close() the session, and the drop still reports as disconnected. ++ const disconnected = this.nextLinkExpected(); + +- if (!messages.err) return requests; ++ link.close(); + +- if (!requests.err) return messages; ++ if (!disconnected) { ++ this.end(); + +- 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; ++ return; ++ } + +- return responseTimeout > 0 ? responseTimeout : defaults.responseTimeout; ++ this.emit('disconnected'); ++ this.reconnectLoop?.schedule(); + } + + /** The session is over now, drained or not. Nothing brings it back. */ + private end(): void { +- this.stop(); +- this.teardown(); +- this.dlrMerger.clear(); +- this.emitClose(); +- } ++ if (this.life === 'closed') return; + +- /** No new sends, and no link after this one. */ +- private stop(): void { +- this.link.stop(); ++ this.life = 'closed'; + this.reconnectLoop?.stop(); +- } +- +- private emitClose(): void { +- if (!this.link.end()) return; +- +- this.outgoing.linkLost(); ++ this.link.close(); ++ this.dlrMerger.clear(); ++ this.outgoing.over(); + this.emit('close'); + } + +- private teardown(): void { +- const lost = this.link.drop(); +- +- if (!lost) return; ++ /** Whether a link that is gone is followed by another. */ ++ private nextLinkExpected(): boolean { ++ return this.life === 'open' && this.reconnectLoop !== undefined; ++ } + +- this.outgoing.linkLost(); +- this.timers.clear(); +- this.incoming.clear(); +- this.sock.destroy(); ++ private loopFor(reconnect: ReconnectOptions | undefined): ReconnectLoop | undefined { ++ if (!reconnect) return undefined; + +- // `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(); ++ return new ReconnectLoop({ ++ connect: reconnect.connect, ++ log: this.log, ++ maxDelay: reconnect.maxDelay, ++ minDelay: reconnect.minDelay, ++ onConnected: sock => this.comeBackUp(sock, reconnect.onConnected), ++ }); + } + +- private onData(chunk: Buffer): void { +- this.emit('data', chunk); +- this.resetTimers(); ++ private responseTimeout(): number { ++ return this.options.responseTimeout ?? defaults.responseTimeout; + } + +- private dispatch(pduObj: PduObject): void { +- if (isResp(pduObj)) { +- if (!this.outgoing.deliver(pduObj)) { +- this.log.debug('session - response with no matching request', { seqNr: pduObj.seqNr }); +- } +- +- return; +- } +- ++ private dispatch(link: Link, pduObj: PduObject): void { + this.emit('incomingPduObj', pduObj); + // Every application hook and listener reached from an incoming PDU funnels through here. +- void this.incoming.handle(pduObj).catch((thrown: unknown) => { ++ void this.incoming.handle(link, pduObj).catch((thrown: unknown) => { + const err = errorFrom(thrown); + + this.log.error('session - a handler threw', { +@@ -418,36 +401,15 @@ export class Session extends EventEmitter { + }); + } + +- /** A PDU the codec refused. Its header parsed, so the peer gets an answer and the link stays. */ ++ /** A PDU the codec refused. Its header parsed, so a request gets an answer and the link stays. */ + private refuse(refused: PduRefusedError): void { + const { cmdId, cmdName, seqNr } = refused.header; + + this.emit('sessionError', refused); + + // A response carries a sequence number of ours, so writing one back lands in the peer's space. +- if (isResp(refused.header)) { +- this.outgoing.settleRefused(seqNr, refused); +- +- return; +- } ++ if (isResp(refused.header)) return; + + 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..92b7524 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 { HeldMessage, 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'; +@@ -17,16 +17,18 @@ import type { SmppServer } from '../src/server.ts'; + import type { TestContext } from 'node:test'; + import { HeldMessages } from '../src/held-messages.ts'; + import { IncomingRequests, refusedSegmentStatus } from '../src/incoming-requests.ts'; ++import { OutgoingRequests } from '../src/outgoing-requests.ts'; + import { UnansweredError } from '../src/unanswered-error.ts'; + import { createSms } from '../src/sms.ts'; +-import { LinkLife } from '../src/link-life.ts'; ++import { Link } from '../src/link.ts'; + import { SendWindow } from '../src/send-window.ts'; + import { Reassembler, decodeSegments } from '../src/reassembly.ts'; + 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,15 +103,41 @@ function abortAfter( + }); + } + +-function incomingOn(session: Session, options: Partial = {}): IncomingRequests { +- 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') }), ++const noop = (): void => undefined; ++ ++/** A link over a socket nobody reads or writes, so what arrives on it is what the test hands it. */ ++function linkOn(session: Session, options: { bound?: boolean; log?: SmppLog } = {}): Link { ++ return new Link({ ++ bound: options.bound ?? true, ++ held: { ++ max: defaults.maxHeldMessages, ++ maxOctets: defaults.maxHeldOctets, ++ sendPastDrain: () => Promise.resolve({ err: new Error('never sent') }), ++ session, ++ timeout: defaults.heldMessageTimeout, ++ }, ++ log: options.log ?? silentLog, ++ on: { data: noop, enquireLink: noop, framed: noop, lost: noop, refused: noop, request: noop }, ++ reassembly: { max: 10, onLost: noop, timeout: 10_000 }, ++ responseTimeout: 100, ++ sock: new net.Socket(), ++ }); ++} ++ ++type Inbound = { close: () => void; handle: (pduObj: PduObject) => Promise; link: Link }; ++ ++/** The inbound path of one link, fed PDUs by hand. */ ++function incomingOn(session: Session, options: Partial = {}): Inbound { ++ const log = options.log ?? silentLog; ++ const link = linkOn(session, { log }); ++ const incoming = new IncomingRequests({ ++ dlrMerger: new DlrMerger({ log, max: 10, timeout: 10_000 }), ++ log, + session, + ...options, + }); ++ ++ return { close: () => { link.close(); }, handle: pduObj => incoming.handle(link, pduObj), link }; + } + + function submitPdu(seqNr: number, cmdStatus: ErrorName = 'ESME_ROK'): PduObject { +@@ -749,21 +777,21 @@ describe('reconnect', () => { + + closeAfter(t, session); + +- const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 100 }); +- const incoming = incomingOn(session, { link, onRequest: async () => { await delay(10); return false; } }); ++ const onRequest = async (): Promise => { await delay(10); return false; }; ++ const incoming = incomingOn(session, { onRequest }); + let messages = 0; + + session.on('sms', () => { messages++; }); + + const handled = incoming.handle(submitPdu(1)); + +- link.drop(); ++ incoming.link.close(); + + await handled; + + assert.equal(messages, 0); + +- await incoming.handle(submitPdu(2)); ++ await incomingOn(session, { onRequest }).handle(submitPdu(2)); + + assert.equal(messages, 1, 'the harness delivers a message whose link stayed'); + }); +@@ -1403,92 +1431,130 @@ describe('sends across a reconnect', () => { + }); + }); + +-describe('LinkLife', () => { +- test('refuses a hold whose deadline has already passed', async () => { ++describe('OutgoingRequests waiting for a link', () => { ++ type View = { closing: boolean; link: Link; nextExpected: boolean }; ++ ++ /** Requests against a link that is down, with the session's answers under the test's control. */ ++ function outgoingOn(t: TestContext, view: View, options: { now?: () => number; responseTimeout: number }): OutgoingRequests { ++ closeAfter(t, view.link.held.session); ++ ++ return new OutgoingRequests({ ++ links: { ++ closing: () => view.closing, ++ current: () => view.link, ++ nextExpected: () => view.nextExpected, ++ }, ++ log: silentLog, ++ maxOutstanding: 10, ++ now: options.now, ++ responseTimeout: options.responseTimeout, ++ }); ++ } ++ ++ function down(): View { ++ return { closing: false, link: linkOn(new Session({ sock: new net.Socket() }), { bound: false }), nextExpected: true }; ++ } ++ ++ // A link released to it that still carries nothing sends it back to wait, and the budget is one. ++ test('refuses a request whose budget ran out while it waited for a link', async t => { + let now = 0; +- const link = new LinkLife({ log: silentLog, now: () => now, reconnects: true, timeout: 100 }); +- const waitForLink = link.hold(undefined); ++ const view = down(); ++ const outgoing = outgoingOn(t, view, { now: () => now, responseTimeout: 100 }); ++ const held = outgoing.request({ cmdName: 'enquire_link' }, {}); + +- link.drop(); + now = 101; ++ outgoing.linkBound(); + +- const held = await waitForLink(); +- +- assert.match(held.err?.message ?? '', /did not come back in time/); ++ assert.match((await held).err?.message ?? '', /did not come back in time/); + }); + + test('holds on a timer that keeps the process alive', async () => { +- const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 10_000 }); ++ const view = down(); ++ const outgoing = new OutgoingRequests({ ++ links: { closing: () => false, current: () => view.link, nextExpected: () => true }, ++ log: silentLog, ++ maxOutstanding: 10, ++ responseTimeout: 10_000, ++ }); + const timers = (): number => process.getActiveResourcesInfo().filter(name => name === 'Timeout').length; +- +- link.drop(); +- + const before = timers(); +- const held = link.hold(undefined)(); ++ const held = outgoing.request({ cmdName: 'enquire_link' }, {}); + + assert.equal(timers(), before + 1, 'an unref\'d timer is not counted here, which is the point'); + +- link.open(); ++ outgoing.over(); + +- assert.deepEqual(await held, {}); ++ assert.match((await held).err?.message ?? '', /Session is closed/); + }); + +- // addEventListener never fires for a signal that already aborted, so it would wait out the timeout. +- 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(); ++ test('gives up at once on a signal that fires while it waits', async t => { ++ const view = down(); ++ const outgoing = outgoingOn(t, view, { responseTimeout: 100 }); ++ const controller = new AbortController(); ++ const held = outgoing.request({ cmdName: 'enquire_link' }, { signal: controller.signal }); + +- const held = await link.hold(AbortSignal.abort())(); ++ controller.abort(); + +- assert.match(held.err?.message ?? '', /Aborted while waiting for a link/); ++ assert.match((await held).err?.message ?? '', /Aborted while waiting for a link/); + }); + +- test('awaits the next link only while down with one on its way', () => { +- const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 100 }); ++ test('waits only while a link is on its way, and is refused as closed otherwise', async t => { ++ const view = down(); ++ const outgoing = outgoingOn(t, view, { responseTimeout: 0 }); ++ const waiting = outgoing.request({ cmdName: 'enquire_link' }, {}); ++ ++ assert.equal(await within(30, waiting), undefined, 'down with a link to come: it waits'); ++ ++ view.nextExpected = false; ++ ++ const refused = await outgoing.request({ cmdName: 'submit_sm' }, {}); ++ ++ assert.match(refused.err?.message ?? '', /closed/, 'down with none to come'); ++ ++ outgoing.over(); + +- assert.equal(link.awaitsNextLink(), false, 'up'); +- link.drop(); +- assert.equal(link.awaitsNextLink(), true, 'down, returning'); +- link.attach(); +- assert.equal(link.awaitsNextLink(), true, 'attached, not yet bound'); +- link.open(); +- assert.equal(link.awaitsNextLink(), false, 'reopened'); +- link.drop(); +- link.stop(); +- assert.equal(link.awaitsNextLink(), false, 'down, stopped'); +- assert.match(link.refusal()?.message ?? '', /closed/, 'stopped while down'); +- link.end(); +- assert.equal(link.awaitsNextLink(), false, 'ended'); ++ assert.match((await waiting).err?.message ?? '', /Session is closed/, 'and what waited is told the same'); + }); + +- test('drops an attached link once, counts each drop, and names the event it warrants', () => { +- const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 100 }); +- const generation = link.generation(); ++ test('refuses a new request during a drain only while a link could carry it', async t => { ++ const view = down(); ++ const outgoing = outgoingOn(t, view, { responseTimeout: 100 }); + +- assert.equal(link.drop(), 'disconnected'); +- assert.equal(link.drop(), undefined, '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.equal(link.generation(), generation + 2); +- assert.equal(new LinkLife({ log: silentLog, reconnects: false, timeout: 100 }).drop(), 'close'); ++ view.closing = true; ++ view.nextExpected = false; ++ ++ assert.match((await outgoing.request({ cmdName: 'enquire_link' }, {})).err?.message ?? '', /Session is closed/); ++ ++ view.link.markBound(); ++ ++ assert.match((await outgoing.request({ cmdName: 'enquire_link' }, {})).err?.message ?? '', /shutting down/); + }); ++}); ++ ++describe('Link', () => { ++ test('closes once, taking the requests waiting on it and its bound-ness with it', async t => { ++ const session = new Session({ sock: new net.Socket() }); + +- test('releases a held request with the reason once the link ends', async () => { +- const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 0 }); ++ closeAfter(t, session); + +- link.drop(); ++ const link = linkOn(session, { bound: false }); + +- const held = link.hold(undefined)(); ++ assert.equal(link.canCarry(), false, 'a reconnect\'s link carries nothing until its bind is answered'); ++ link.markBound(); ++ assert.equal(link.canCarry(), true); + +- link.end(); ++ const waiting = link.pending.wait(1, { timeout: 0 }); + +- 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.close(); ++ link.close(); ++ ++ assert.equal(link.isClosed(), true); ++ assert.equal(link.canCarry(), false); ++ assert.equal(link.sock.destroyed, true); ++ assert.match((await waiting).err?.message ?? '', /Session closed before a response arrived/); ++ ++ link.markBound(); ++ assert.equal(link.canCarry(), false, 'closed is final'); + }); + }); + +@@ -1542,7 +1608,7 @@ describe('held message bounds', () => { + return [submitPdu(seqNr)]; + } + +- function offer(held: HeldMessages, seqNr: number): MessageHold { ++ function offer(held: HeldMessages, seqNr: number): HeldMessage { + const hold = held.offer(message(seqNr)); + + assert.ok(hold); +@@ -1562,7 +1628,6 @@ describe('held message bounds', () => { + + return new HeldMessages({ + ...options, +- link: new LinkLife({ log: silentLog, reconnects: false, timeout: 100 }), + log: silentLog, + sendPastDrain: () => Promise.resolve({ err: new Error('never sent') }), + session, +@@ -1658,7 +1723,7 @@ describe('held message bounds', () => { + + assert.equal(answers.at(-1), 'ESME_RTHROTTLED'); + assert.equal(warnings.length, 1); +- incoming.clear(); ++ incoming.close(); + }); + + test('holds a message detached from the chunk it was read from', async t => { +@@ -1678,7 +1743,7 @@ describe('held message bounds', () => { + + assert.ok(Buffer.isBuffer(retained)); + assert.notEqual(retained.buffer, chunk.buffer); +- incoming.clear(); ++ incoming.close(); + }); + + test('gives up on a message the application never answers', t => { diff --git a/docs/comprehension-rewrite/drafts/draft-b.patch b/docs/comprehension-rewrite/drafts/draft-b.patch new file mode 100644 index 0000000..6df24c3 --- /dev/null +++ b/docs/comprehension-rewrite/drafts/draft-b.patch @@ -0,0 +1,1971 @@ +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 - 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 { ++ if (remaining() === 0) return Promise.resolve(0); ++ ++ if (signal?.aborted === true) return Promise.resolve(remaining()); ++ ++ return new Promise(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; ++ /** Resolves 0 once every request sent is answered, or with how many are not. */ ++ requestsUnfinished: (timeout: number, signal: AbortSignal | undefined) => Promise; ++}; ++ ++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 { ++ // 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; +- + /** +- * 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> { +- 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; ++ private readonly held: ExpiringGroups; + 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(); +- private readonly route: HoldRoute; ++ private readonly offered = new WeakMap(); ++ 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 { +- if (remaining() === 0) return Promise.resolve(0); +- +- if (signal?.aborted === true) return Promise.resolve(remaining()); +- +- return new Promise(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 = { + + 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 { +- 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 { + 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 { +- 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(); + 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 { ++ budget(signal: AbortSignal | undefined): () => Promise { + 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 { +- 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> { ++ /** 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> { + 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 { +- 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 { ++ 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, ++ ): Promise> { ++ 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 { + + 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 { + + 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 { + 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 { + 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 { + 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 { + 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 { + */ + async unbind(): Promise { + 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 { + async close(options: CloseOptions = {}): Promise { + 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 { ++ 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 { + 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 { + sock: Socket, + bind: (session: Session) => Promise, + ): Promise { +- 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 { +- 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 { + + 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> = {}, ++): 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 = {}): 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; diff --git a/docs/comprehension-rewrite/drafts/draft-c.patch b/docs/comprehension-rewrite/drafts/draft-c.patch new file mode 100644 index 0000000..b5a253a --- /dev/null +++ b/docs/comprehension-rewrite/drafts/draft-c.patch @@ -0,0 +1,9078 @@ +diff --git a/AGENTS.md b/AGENTS.md +index 882bb24..1ca2269 100644 +--- a/AGENTS.md ++++ b/AGENTS.md +@@ -40,38 +40,43 @@ src/ + index.ts Public surface. Named exports only, no default export. + 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 +- 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 ++ session.ts Session: the public methods and events, the link's life, and the collaborators under session/ ++ defaults.ts Every option's default, and the bounds that are not options + 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 +- 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 + 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 +- 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 + result.ts Result — the shape every fallible call returns +- 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 +- 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 +- uuid.ts uuidv7() — the ids the library generates for messages ++ session/ What a Session is made of; nothing here is exported ++ bind-direction.ts Bind types, which end of the link this is, which commands a bind carries ++ idle-waiters.ts IdleWaiters: waiting for a count to fall to zero, and what is left of a budget ++ incoming-requests.ts Every request the peer sends: the answer each gets, and the message or report it becomes ++ link-life.ts LinkLife: whether the link carries requests, and where one waits for the next link ++ link-timers.ts LinkTimers: the enquire_link heartbeat and the idle timeout ++ outgoing-requests.ts OutgoingRequests: one request() in three lanes, the window, the pending map and the retry ++ pdu-transport.ts PduTransport: the socket a session reads complete PDUs off ++ pending-requests.ts PendingRequests: sequence numbers, correlation, timeout, abort ++ reconnect-loop.ts ReconnectLoop: backoff, retry timer, stopped-ness ++ running-handlers.ts RunningHandlers: the onSms handlers in flight, their bound, and what a drain waits for ++ send-window.ts SendWindow: the maxOutstanding semaphore ++ session-options.ts SessionOptions, SessionEvents, SmsHandler, ReconnectOptions and the option checks ++ messages/ Messages and receipts, on the way in and out ++ 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 ++ expiring-groups.ts ExpiringGroups: the capped, weighed, expiring store DlrMerger and Reassembler share ++ 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 ++ reassembly.ts Reassembler: capped, expiring multipart groups ++ 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 ++ sms.ts Sms: the handle onSms gets, already answered, and its sendDlr() ++ 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 ++ uuid.ts uuidv7() — the ids the library generates for messages ++ wire/ The codec and the framing ++ 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 + defs/ + commands.ts The 33 commands, their ids and ordered parameter lists + constants.ts consts + constsById, and the SMPP version constants +@@ -82,10 +87,10 @@ src/ + types.ts Wire types: int8/int16/int32/string/cstring/buffer/arrays + ``` + +-Imports point one way: `defs` knows nothing above it but `result.ts`, `pdu` uses `defs`, `session` +-uses `pdu`, and `client`/`server` use `session`. The ways back up are the `Session` handed to +-`createSms()`, `HeldMessages` and `IncomingRequests`, which call back into it, and to `OnRequest` +-and `onConnected` in `session-options.ts`, all imported as a type only. ++Imports point one way: `defs` knows nothing above it but `result.ts`, `wire` uses `defs`, ++`messages` uses `wire`, `session/` uses both, and `client`/`server` use `session`. The ways back up ++are the `Session` handed to `createSms()` and `IncomingRequests`, which call back into it, and to ++`OnRequest`, `SmsHandler` and `onConnected` in `session-options.ts`, all imported as a type only. + + **Parameter order is wire order.** The key order inside `cmds.*.params` is the order the fields are + written to and read from the buffer. Never sort those alphabetically — the alphabetical-ordering +@@ -278,7 +283,7 @@ this is not a changelog. + + - A close arriving after our own `unbind` is a clean unbind, not an error. + - `close` means the session is over, and a drop the loop will retry is `disconnected`. +-- An answer belongs to the link the message arrived on; a receipt does not. ++- An answer goes out on the socket the request arrived on; a receipt on whichever link is up. + - `reconnect` takes `{ minDelay, maxDelay }` to retune and `false` to turn off + - Coming up is not proof a link works, so only one that outlasted `maxDelay` resets the backoff. + - `reconnect: { fromStart: true }` puts the first connect and bind through that same loop, and +@@ -288,15 +293,13 @@ this is not a changelog. + - A stream this library cannot frame is a dead link; one PDU it cannot parse is not. + - A deliberate shutdown drains; an unusable link and an abort do not. + - `sendSms()` puts every segment of a message on the wire together. +-- Every segment of a concatenated message is answered as it arrives, so `sendResp()` on one is the +- application's own signal rather than the peer's answer. ++- Every message is answered on arrival, and `onSms` is a handler the session waits for rather than ++ an event. + - `server()` composes the application's `onRequest` after its own bind handling, and offers it every + request that handling did not answer. +-- The drain waits on the messages the application holds, and `sendResp()` is what says it is done +- with one. +-- The drain's wait on the application ignores `shutdownTimeout: 0`. +-- What the application holds unanswered is capped on constants, and a message past the cap is +- refused. ++- The drain waits on the `onSms` handlers still running, then on the requests on the wire, and a ++ receipt is let past it. ++- Running handlers are capped on a constant, and a message past the cap is refused. + - A store at its bound answers `ESME_RTHROTTLED` to a submission and `ESME_RX_T_APPN` to a delivery, + a `data_sm` by whichever it stands in for. + - A reconnect keeps the delivery-receipt merges; everything else the link held is dropped. +@@ -304,7 +307,8 @@ this is not a changelog. + - A message id base is merged at most once. + - A send that never reached the socket waits for the next link; one that did is counted, not resent. + - A send queued for a send-window slot is bounded by the caller's `signal`, and by nothing else. +-- One owner decides whether a link can carry a request, and a bind is what makes it one. ++- One owner decides whether a link can carry a request, and `bound()` is what makes it one. ++- Every request leaves through one `request()`, in one of three lanes. + + ### [Internals and tests](docs/decisions.md#internals-and-tests) + +@@ -314,7 +318,7 @@ this is not a changelog. + `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 +-- `src/` stays flat until a module has to move for another reason. ++- `src/` is grouped by what a reader is looking for: the session's parts, the messages, the wire. + - `test/` stays flat too, and a file there is named for the question it answers rather than for the + module it covers. + - CI tests on Linux only; `src/` keeps off what is known to break on macOS or Windows. +diff --git a/CHANGELOG.md b/CHANGELOG.md +index 7cb5ff7..d7db661 100644 +--- a/CHANGELOG.md ++++ b/CHANGELOG.md +@@ -40,11 +40,29 @@ + count as next to nothing, so a peer could hold far more than the cap. **Raise a `maxOctets` you + tuned low**: it now holds several times fewer segments, and an incomplete message evicted over + the cap is lost, since its segments were already answered. +-- A message arriving while the application holds 1000 unanswered, or 64 MiB of them counted the way +- `maxOctets` counts segments, is refused with `ESME_RTHROTTLED` (`ESME_RX_T_APPN` on a +- delivery), so the peer keeps it and retries. **Call `sendResp()` on every `sms`, multipart +- included**: 1000 left unanswered now stop inbound traffic for up to five minutes, where the oldest +- used to be dropped with a warning. ++- **An inbound message goes to the `onSms` option instead of the `sms` event, and arrives already ++ answered.** `client({ onSms })`, `server({ onSms })` and `new Session({ onSms })` take one ++ handler; `session.on('sms', …)` is gone. Every message, single or multipart, is answered ++ `ESME_ROK` with a generated UUID v7 (`sms.smsId`, `-` per segment) before the handler ++ runs, so `sms.sendResp()`, `SendRespOptions` and `sms.answeredOnArrival` are gone with it. Refuse ++ a submission from `onRequest`, where it is still unanswered; store your own id against ++ `sms.smsId`. A session with no `onSms` refuses every message `ESME_RX_P_APPN`, where a session ++ with no `sms` listener used to leave it unanswered until the peer gave up. ++- `close()` and `unbind()` wait for the `onSms` handlers still running (the promise each returned, ++ up to five minutes each), then for the requests on the wire, and report `N handler(s) still ++ running` in place of `N message(s) unanswered`. `shutdownTimeout: 0` now waits for the handlers ++ as it waits for the requests, where it used to fall back to `responseTimeout` for them. ++ `sms.sendDlr()` is never refused by a shutdown, whenever it is called. ++- A message arriving while 1000 `onSms` handlers are still running is refused with ++ `ESME_RTHROTTLED` (`ESME_RX_T_APPN` on a delivery), so the peer keeps it and retries; the 64 MiB ++ bound on held messages is gone with the hold. **Return from `onSms` when you are done with the ++ message**: 1000 handlers that never settle stop inbound traffic for up to five minutes. ++- `encoding: 'ASCII'` is `encoding: 'GSM7'`, the alphabet it always was; `'ASCII'` is refused by ++ name. `encodings.GSM7`, `dataCodingByEncoding.GSM7` and `encodeMessage()`'s `encoding` rename ++ with it. ++- A `Session` you construct yourself carries `submit_sm` and `deliver_sm` only once `bound()` has ++ recorded a bind; `bind_*`, `enquire_link` and `unbind` go out at once, through no send window. ++ `client()` and `server()` sessions are unchanged. + - A `submit_sm` segment the reassembly buffer has no room for is refused with `ESME_RTHROTTLED`, + where it was `ESME_RMSGQFUL`. + - `server()` refuses a `maxOctets` below 1 or not a whole number, `Infinity` included, like its +diff --git a/DESIGN.md b/DESIGN.md +new file mode 100644 +index 0000000..6bf8bb1 +--- /dev/null ++++ b/DESIGN.md +@@ -0,0 +1,84 @@ ++# Draft C: the message is answered on arrival, the handler is what the session waits for ++ ++Round one showed the internals were hard because the contract was: a message the application had ++to answer, a drain that had to know when it was "done", and a receipt that had to slip past that ++drain one turn later. This draft changes the contract so none of that exists. ++ ++## The public contract, as an implementer sees it ++ ++- **Receiving.** `client({ onSms })`, `server({ onSms })`, `new Session({ onSms, sock })`: one ++ handler, `(sms: Sms) => unknown`. Every message reaches it already answered `ESME_ROK` under ++ `sms.smsId` (a UUID v7; `-` per segment of a long message), single and multipart alike. ++ No handler: every message is refused `ESME_RX_P_APPN`. 1000 handlers still running: the next ++ message is refused `ESME_RTHROTTLED`/`ESME_RX_T_APPN` so the peer retries. A handler that throws ++ or rejects is a `sessionError`; its message was answered before it ran. ++- **Answering.** Nothing to call. To refuse a submission, answer it from `onRequest`, which runs ++ before the library answers. `sms.sendResp()`, `SendRespOptions` and `answeredOnArrival` are gone. ++- **Sending.** `sendSms()`, `send()`, `sms.sendDlr()` as before. A shutdown refuses the first two and ++ never `sendDlr()`. A request on a bound link with a free slot is on the socket before the call ++ returns, so a handler's `void sms.sendDlr()` is counted by the drain with no timing rule. ++- **Shutting down.** `close()`/`unbind()`: refuse new sends; wait up to `shutdownTimeout` for the ++ handlers still running (their promise, or five minutes), then for the requests on the wire; tear ++ down. The `err` says `N handler(s) still running` and/or `N request(s) unfinished`. `0` waits ++ forever for both; nothing falls back to `responseTimeout` any more. ++- **A hand-wired `Session`** carries `submit_sm`/`deliver_sm` once `bound()` records a bind; bind, ++ `enquire_link` and `unbind` go out at once, outside the window. `encoding: 'ASCII'` is `'GSM7'`. ++ ++## Internal structure and who owns which state ++ ++``` ++src/session.ts Session: public methods and events; the link's three transitions, each one method: ++ linkLost() (socket died/idle/unreadable/failed rebind), end() (over), bound() (opens the link) ++src/session/ ++ link-life.ts phase binding|up|down|ended + stopped; the waiters for a link. Starts `binding`; bound() opens. ++ outgoing-requests ONE request(input, options, lane). message: drain refusal, link wait, window, retry on ++ next link. receipt: no drain refusal. link: straight onto the socket. Sync fast path. ++ incoming-requests the peer's requests: hook -> socket-identity check -> bind gate -> per command. ++ Answers every message itself, then hands it to RunningHandlers. ++ running-handlers count of onSms handlers in flight, the 1000 bound with its log hysteresis, the ++ 5-minute deadline, idle() for the drain. Replaces HeldMessages + MessageHold. ++ bind-direction bind types, LinkEnd, linkCommands, bindCarries, checkedBind (out of session-options) ++ session-options option types, SessionEvents (no `sms`), SmsHandler, OnRequest, the option checks ++src/defaults.ts every default and every hard bound, once. client/server/session read it. ++src/messages/ sms (the handle, no sendResp), send-sms, dlr, dlr-merger (close -> spend), reassembly, … ++src/wire/ pdu, pdu-framer, pdu-refusal ++``` ++ ++`Session` still hands itself to `IncomingRequests` and `createSms()`: `OnRequest` and `Sms.session` ++are public types that name `Session`, so a narrower port would be a second name for the same thing. ++What the router may do is now visible in one file: `sendReturn`, `emit`, `close`, `bindAllows`. ++ ++## Deleted ++ ++`held-messages.ts` (HeldMessages, MessageHold, six exits, the WeakMap, `listenerRejected`, the ++`setImmediate`), `Session.listenerCount` use, `LinkLife.generation()`/`hold()`, ++`OutgoingRequests.requestPastDrain`/`requestOnCurrentLink`/`drain`, `SendWindow.acquire` (now ++`take()` + `wait()`), `Session.teardown`/`stop`-then-`emitClose` dance, `Session.answering()`, ++`IncomingRequests.refusing`, three `defaults` objects, `backoffDefaults`, `defaultMaxOctets`, ++`defaultInterfaceVersion`, `Sms.sendResp`/`answeredOnArrival`, `SendRespOptions`, `SmsHandlers`, ++`SmsInput.answeredAs`, the `sms` event, the `ASCII` encoding name. 35 -> 37 files outside `defs/`, grouped. ++ ++## Tests ++ ++`docker compose run --rm node npm test`: lint and typecheck clean, **511 tests, 511 pass, 0 fail** ++(baseline 512). Changed, never weaker on the wire: ++ ++- Everywhere: `session.on('sms', …)` -> the `onSms` option; `sendResp()` deleted, and a test that ++ named its own id now asserts the response carries `sms.smsId`; `answeredOnArrival` assertions gone. ++ A test that needs a request left unanswered (window held, `unanswered` counted, a drain measured) ++ holds it in an `onRequest` hook returning `true` and answers it with `sendReturn()` — the wire the ++ test observes is unchanged. `'ASCII'` -> `'GSM7'`. ++- `session-extras`: "held message bounds" -> "running handler bounds" (full at 1000, throttle status, ++ one warn, `ESME_RX_P_APPN` with no handler, deadline, drain wake-up); "sendResp()" and "refuses an ++ id and a refusing status for segments already on the wire" deleted (premise gone); "refuses to ++ answer a message whose link went" -> "sends the receipt … on the new link"; "answers a ++ single-segment message only once the application does" -> "on arrival, with the id the handler ++ reads"; "falls back to responseTimeout at shutdownTimeout 0" -> "waits for a handler as long as it ++ runs"; "still ends when the message half has spent the budget" drives a raw peer; "waits for the ++ listener still working when another one rejected" and "leaves a message the library refused to ++ answer unanswered" deleted (one handler; nothing to refuse); LinkLife tests use `budget()`, ++ boolean `drop()` and start unbound; SendWindow tests use `take()`/`wait()`. ++- `session.test.ts`: "keeps at most maxOutstanding requests on the wire" measures concurrency in an ++ `onRequest` hook; the `sendResp({ smsId: '' })` sub-assertion is gone; `readme.test.ts` follows the ++ new README examples; `interop-tests/*` and `benchmarks/smsc-sink.ts` (typechecked, not run here) ++ hold requests via `onRequest`, and dumbclient S9 measures running handlers, not held messages. +diff --git a/MIGRATION-NOTES.md b/MIGRATION-NOTES.md +new file mode 100644 +index 0000000..4c5d46d +--- /dev/null ++++ b/MIGRATION-NOTES.md +@@ -0,0 +1,18 @@ ++# Breaking changes for a 0.5.0 user ++ ++| Was | Now | ++| --- | --- | ++| `session.on('sms', async sms => { … })` | `client({ onSms: sms => { … } })`, `server({ onSms })` or `new Session({ onSms, sock })`: one handler, given at construction. | ++| `smpp.on('session', s => s.on('sms', …))` | `server({ onSms: sms => { … } })`; `sms.session` is the session the message came in on. | ++| `await sms.sendResp()` | Delete it: every message is answered `ESME_ROK` on arrival, before the handler runs. | ++| `sms.sendResp({ smsId: myId })` | Store `myId` against `sms.smsId`, the UUID v7 the peer was answered with. | ++| `sms.sendResp({ status: 'ESME_RMSGQFUL' })` | `server({ onRequest: async (session, pduObj) => { …; await session.sendReturn(pduObj, 'ESME_RMSGQFUL'); return true; } })`. | ++| `if (sms.answeredOnArrival) …` | Delete the branch: it is always the case. | ++| `import type { SendRespOptions }` | Delete it; `SmsHandler` is the new exported type. | ++| `session.on('sms', listener)` with no listener at all | A session with no `onSms` refuses every message `ESME_RX_P_APPN` instead of leaving it unanswered. | ++| `close()` waits for "unanswered messages" | It waits for `onSms` handlers still running; `err.message` reads `… N handler(s) still running` instead of `… N message(s) unanswered`. | ++| `shutdownTimeout: 0` falls back to `responseTimeout` for the messages | It waits for the handlers as for the requests; each handler is bounded by its own five-minute deadline. | ++| `sendDlr()` straight after `sendResp()` to get past a drain | `sendDlr()` is never refused by a shutdown; call it whenever. | ++| 1000 unanswered messages or 64 MiB of them throttle the peer | 1000 running handlers throttle the peer; the octet bound is gone. | ++| `encoding: 'ASCII'` | `encoding: 'GSM7'`; likewise `encodings.GSM7`, `dataCodingByEncoding.GSM7`, `encodeMessage(…).encoding === 'GSM7'`. | ++| `new Session({ sock })` then `session.send({ cmdName: 'submit_sm' })` | Call `session.bound('transceiver', 0x34)` (or have the peer bind) first; bind, `enquire_link` and `unbind` still go out at once. | +diff --git a/MIGRATION.md b/MIGRATION.md +index 0296ede..04ca649 100644 +--- a/MIGRATION.md ++++ b/MIGRATION.md +@@ -6,15 +6,16 @@ shape is the same, connect, send, listen for delivery reports, with callbacks re + ## API changes + + - **The package is `@larvit/smpp`** and ESM only. `require()` no longer works. +-- **Callbacks are gone.** `client`, `server`, `sendSms`, `sendResp`, `sendDlr`, `unbind` and +- `session.close` are promises resolving to a result with an optional `err`. Nothing rejects. Await +- `close()`, or the socket outlives the call. ++- **Callbacks are gone.** `client`, `server`, `sendSms`, `sendDlr`, `unbind` and `session.close` ++ are promises resolving to a result with an optional `err`. Nothing rejects. Await `close()`, or the ++ socket outlives the call. ++- **The `sms` event is the `onSms` option**, on `client()`, `server()` and `Session`, and every ++ message reaches it already answered: `sendResp()` is gone. Refuse a submission from `onRequest`. + - **`server()` resolves once, when it is listening**, with a handle carrying `close()`, `port` and + a `session` event. It no longer calls back once per connection. +-- **The id a message is answered with goes to `sendResp({ smsId })`.** `sms.smsId` is read-only: the +- id the segments were answered with, the id `sendResp()` was given, or the generated UUID v7. +- Assigning to it throws a `TypeError` in strict-mode code (every ES module, and any file under +- `'use strict'`), and is ignored otherwise. ++- **The id a message is answered with is the library's**: `sms.smsId` is the generated UUID v7 the ++ peer already has, and the segments of a long message were answered `-`. Store your own ++ id against it. + - **`smsIds` from `sendSms()` is `(string | undefined)[]`**, one entry per segment, positional with + `pduObjs`, `undefined` where the SMSC took the segment without naming an id. + - **`checkuserpass` is `authenticate`**, takes `{ password, session, systemId, systemType }` and +@@ -44,7 +45,8 @@ shape is the same, connect, send, listen for delivery reports, with callbacks re + - **`log`** takes any object with `debug`, `error`, `info`, `verbose` and `warn` methods instead of + a `larvitutils` one, and is silent by default: [README](README.md#logging). + - **`consts.ENCODING.ASCII` is gone**; the same entry is `consts.ENCODING.IA5`, the other name SMPP +- 3.4 5.2.19 gives 0x01. `dataCodingByEncoding` is the alphabet `sendSms()` writes, which is 0x00. ++ 3.4 5.2.19 gives 0x01. The `encoding` option's GSM 03.38 alphabet is `GSM7`, and ++ `dataCodingByEncoding.GSM7` is what `sendSms()` writes it under, which is 0x00. + + ## Behaviour that changed on the wire + +diff --git a/README.md b/README.md +index 9ff2b7d..06168a0 100644 +--- a/README.md ++++ b/README.md +@@ -10,7 +10,8 @@ window, long messages and delivery receipts. TypeScript, ESM, no dependencies. + - **Send window.** 10 requests in flight; further sends queue instead of overrunning the SMSC. + - **Long messages.** Split on send, reassembled on receive, in both the UDH and `sar_*` spellings. + - **Delivery receipts.** Read from TLVs or from receipt text, matched to the ids you were given. +-- **Graceful shutdown.** `close()` waits for what is in flight, so neither end has to guess. ++- **Graceful shutdown.** `close()` waits for your handlers and for what is on the wire, so neither ++ end has to guess. + - **Never throws.** Every fallible call resolves to `{ err?, … }`. + - **Interoperable.** Tested as a client against Jasmin and SMPPSim, and as a server against Kannel, + jsmpp, Cloudhopper, python-smpplib and php-smpp: +@@ -92,34 +93,33 @@ receipts into one, and SMSCs that write ids in two notations: [Delivery receipts + + ## Receive SMS + +-A `receiver` or `transceiver` client gets mobile-originated messages as `sms` events: ++A `receiver` or `transceiver` client hands mobile-originated messages to its `onSms` handler: + + ```javascript +-session.on('sms', async sms => { +- // sms.from, sms.to, sms.message +- await sms.sendResp(); ++const { err, session } = await client({ ++ onSms: sms => { ++ // sms.from, sms.to, sms.message, sms.smsId ++ }, + }); + ``` + +-Call `sendResp()` for every message, multipart included: until you do, it counts toward the bound +-past which the peer's messages are refused. Delivery receipts reach you as `dlr` events, not here. A +-multipart message arrives reassembled and already answered segment by segment, so `sendResp()` there +-puts nothing on the wire and releases it: [Receiving in depth](#receiving-in-depth). ++Every message reaches the handler already answered: the peer got `ESME_ROK` and `sms.smsId` when ++it arrived, whole or reassembled from segments. The handler's promise is what the session waits ++for on shutdown, and while 1000 of them are running new messages are refused so the peer retries. ++Without a handler, every message is refused. Delivery receipts reach you as `dlr` events, not here: ++[Receiving in depth](#receiving-in-depth). + + ## Run an SMPP server + + ```javascript + import { server } from '@larvit/smpp'; + +-const { err, server: smpp } = await server(); +-if (err) throw err; +- +-smpp.on('session', session => { +- session.on('sms', async sms => { +- // sms.from, sms.to, sms.message, sms.dlr +- await sms.sendResp(); +- }); ++const { err, server: smpp } = await server({ ++ onSms: sms => { ++ // sms.from, sms.to, sms.message, sms.dlr, sms.smsId, sms.session ++ }, + }); ++if (err) throw err; + ``` + + With authentication and delivery reports: +@@ -134,34 +134,25 @@ const { err, server: smpp } = await server({ + + return { userData: { userId: 123 } }; + }, +-}); +-if (err) throw err; +- +-smpp.on('session', session => { +- session.on('sms', async sms => { +- if (sms.answeredOnArrival) { +- await sms.sendResp(); // multipart: already answered per segment; this only releases the shutdown drain +- } else { +- // no args: ESME_ROK + generated id; or sendResp({ smsId, status: 'ESME_RMSGQFUL' }) +- await sms.sendResp(); +- } +- ++ onSms: async sms => { ++ // Already answered ESME_ROK under sms.smsId; sms.session.userData is what authenticate returned. + if (sms.dlr) { + await sms.sendDlr(); // same as sms.sendDlr('DELIVERED') + } +- }); ++ }, + }); ++if (err) throw err; + + console.log(smpp.port); // the port actually bound, useful when 0 was requested + await smpp.close(); // stop listening, then drain and close every live session + ``` + +-- `sendResp()` answers `ESME_ROK` with a generated UUID v7 as the message id. +- `sendResp({ smsId, status })` names the id or refuses the message. ++- Every submission is answered `ESME_ROK` as it arrives, with a generated UUID v7 as the message ++ id: `sms.smsId`, and `-1`, `-2` and so on for the segments of a long message. To ++ refuse one, answer it from `onRequest` before the library does: [Server in depth](#server-in-depth). + - `sms.dlr` is true where the sender asked for a receipt. `sendDlr()` reports `DELIVERED`, +- `sendDlr('UNDELIVERABLE')` any other state: [Server in depth](#server-in-depth). +-- A message that arrived in several segments was answered as they arrived, so `sendResp()` there +- takes no `smsId` or refusing `status`. `sms.answeredOnArrival` says which case you are in. ++ `sendDlr('UNDELIVERABLE')` any other state. ++- One `onSms` serves every session the server accepts; `sms.session` is the one a message came in on. + + ## Errors + +@@ -182,8 +173,8 @@ Neither is named `error`, because Node throws on an unhandled `error` event. + | Kind | Type | | + | --- | --- | --- | + | A PDU the peer sent that the codec could not read. The link stays up; only that PDU is lost. | `PduRefusedError` | Count it. | +-| A concatenated message given up on before it was whole. Its segments were answered, so the peer will not resend, and no `sms` fired for it. | `Error` | Count it as lost traffic. | +-| The session or socket failing, or a hook or listener that threw or rejected. | `Error` | Alert. | ++| A concatenated message given up on before it was whole. Its segments were answered, so the peer will not resend, and `onSms` never saw it. | `Error` | Count it as lost traffic. | ++| The session or socket failing, or a hook, handler or listener that threw or rejected. | `Error` | Alert. | + + The last two are told apart by message text only, so this alerts on both: + +@@ -209,7 +200,7 @@ session.on('sessionError', err => { + `seqNr`. `cmdName` is undefined for a command id this library does not know. `PduHeader` is its type. + - A refused request is answered with the status SMPP names for it. A refused response is answered + with nothing, and settles the request it named as `unanswered`. +-- A refused inbound `deliver_sm` is lost traffic: a message or receipt that never arrives as `sms` ++- A refused inbound `deliver_sm` is lost traffic: a message or receipt that never reaches `onSms` + or `dlr`. A refused response is reported twice, as the `err` of the `sendSms()` or `send()` + waiting on it and here. + - A `PduRefusedError` is always a PDU that arrived. What this library refuses to build or send (an +@@ -231,8 +222,9 @@ All optional. Timeouts and delays are milliseconds. + | `enquireLinkInterval` | `20000` | Interval between `enquire_link` on a quiet link. | + | `idleTimeout` | `2 × enquireLinkInterval` | Give up on a link the peer has stopped answering, and re-bind unless `reconnect` is `false`. | + | `responseTimeout` | `30000` | How long to wait for a response, and how long a send with no link waits for the next one. `0` waits forever. | +-| `shutdownTimeout` | `5000` | How long `close()` and `unbind()` wait for requests already sent and messages not yet answered. `0` waits forever for the requests, which end when the peer answers or `responseTimeout` expires, so both at `0` never ends. The messages then fall back to `responseTimeout`, or to its default where that is `0` too. | ++| `shutdownTimeout` | `5000` | How long `close()` and `unbind()` wait for `onSms` handlers still running and requests already sent. `0` waits forever: a handler ends when its promise settles or five minutes pass, a request when the peer answers or `responseTimeout` expires, so `0` for both never ends. | + | `maxOutstanding` | `10` | Requests on the wire at once; further sends queue. | ++| `onSms` | none | `(sms) => Promise \| void`: every mobile-originated message, already answered. Without one, the SMSC's deliveries are refused: [Receive SMS](#receive-sms). | + | `smsIdFormat` | — | The notation the SMSC writes message ids in, per place: `{ receipt: 'decimal', submitResp: 'hex' }`. Only where the two disagree: [Delivery receipts](#delivery-receipts). | + | `reconnect` | on | `{ minDelay, maxDelay }` retunes the backoff; `false` turns it off, so a drop ends the session; `{ fromStart: true }` retries the first connect too. | + | `log` | silent | Any object with `debug`, `error`, `info`, `verbose` and `warn` methods: [Logging](#logging). | +@@ -257,6 +249,7 @@ All optional. Timeouts are milliseconds. + | `host`, `port` | all interfaces, `2775` | Where to listen. `port: 0` takes any free port; `smpp.port` says which. | + | `authenticate` | accept everything | `({ password, session, systemId, systemType }) => false \| { userData }`, sync or async. | + | `onRequest` | none | `(session, pduObj) => true \| false`, sync or async. First refusal on every request a bound peer sends: [Server in depth](#server-in-depth). | ++| `onSms` | none | `(sms) => Promise \| void`: every message a bound peer submits, on any session, already answered. Without one, submissions are refused: [Run an SMPP server](#run-an-smpp-server). | + | `systemId` | `''` | The SMSC identity returned in the bind response. | + | `interfaceVersion` | `0x34` | The SMPP version advertised in the bind response. Optional parameters are sent to a peer from `0x34` up, whatever this is set to. | + | `tls` | `false` | A `tls.TlsOptions` object with your certificate and key. A bare `true` is refused. | +@@ -298,11 +291,11 @@ refuse a non-ASCII sender of its own accord, which reaches you as a refusal such + + | `encoding` | Alphabet | Characters per SMS | Per segment of a long message | + | --- | --- | --- | --- | +-| `ASCII` | GSM 03.38 7-bit | 160 | 153 | ++| `GSM7` | GSM 03.38 7-bit | 160 | 153 | + | `LATIN1` | ISO 8859-1 | 140 | 134 | + | `UCS2` | UCS-2 | 70 | 67 | + +-- Omitted: `ASCII` where the message fits GSM 7-bit, otherwise `UCS2`. `LATIN1` only when named. ++- Omitted: `GSM7` where the message fits GSM 7-bit, otherwise `UCS2`. `LATIN1` only when named. + Any other name is refused. + - GSM extension characters (`{}[]\~^|€` and form feed) count as two, as does a character outside + the basic multilingual plane in `UCS2`. +@@ -357,9 +350,11 @@ holds for `session.send()`. + + ### Events + ++An inbound message is not an event: it goes to the `onSms` handler, because the session waits for ++that handler where it waits for no listener. ++ + | Event | Fires when | + | --- | --- | +-| `sms` | An SMS arrives, reassembled if it was multipart. Carries `sendResp()`, `sendDlr()` and `smsId`. | + | `dlr` | A delivery report arrives, one per segment, with its PDU as the second argument: [Delivery receipts](#delivery-receipts). | + | `messageDlr` | Every segment of a long message sent with `dlr: true` has a final report: [Delivery receipts](#delivery-receipts). | + | `close` | The session is over and nothing will bring the link back. Fires once, whether you closed it or the link failed for good. | +@@ -376,17 +371,16 @@ holds for `session.send()`. + + **Shutdown.** `close()` and `unbind()` both: + +-1. Refuse further sends. `sendDlr()` is the one send let past, when issued straight after +- `sendResp()`; await anything in between and it races the shutdown like any other send. +-2. Wait up to `shutdownTimeout` for the requests already sent, and for every `sms` the application +- has not answered. That wait ends when `sendResp()` puts the response on the wire (or, for a +- message answered on arrival, when it is called at all), or when every listener that took the +- message has failed. Answering through `sendReturn()` instead leaves the wait running. +-3. Tear down what is left, resolving to an `err` that says what was lost. ++1. Refuse further `sendSms()` and `send()` calls. `sms.sendDlr()` is never refused by a shutdown: ++ it reports on a message this session took, and goes out like any request already on the wire. ++2. Wait up to `shutdownTimeout` for every `onSms` handler still running, then for the requests ++ already sent. A handler is running until the promise it returned settles, or five minutes pass. ++3. Tear down what is left, resolving to an `err` that says what was lost: handlers still running, ++ requests unfinished, or a link that went mid-drain. + +-A message left unanswered for five minutes is no longer waited for. + `close({ signal })` cuts the wait short. `unbind()` takes no signal, and waits a further +-`responseTimeout` for its own response. ++`responseTimeout` for its own response. `SmppServer.close()` reports each session's unfinished ++drain as `serverError`. + + **Sends and the link.** + +@@ -396,9 +390,7 @@ A message left unanswered for five minutes is no longer waited for. + you abort it, fails and counts in `unanswered`: the SMSC may have taken it and lost only the + response. + - With `reconnect: false` a drop ends the session, and every send after it is refused. +-- `sms.sendResp()` on a message whose link dropped writes nothing and returns `err`, since a response +- carries the sequence number of the link it arrived on. `sms.sendDlr()` still goes out on the new +- link. ++- `sms.sendDlr()` for a message whose link dropped goes out on the new link. + - `responseTimeout` bounds the wait for a link and the wait for an answer separately, and the wait + for a `maxOutstanding` slot is unbounded, so it is not a deadline. For a deadline pass + `{ signal: AbortSignal.timeout(ms) }`: it cuts all three waits short, and a send it stops before +@@ -437,22 +429,23 @@ const { err, pduObj } = await session.send({ + ## Receiving in depth + + - **Multipart.** Segments tied together by a user data header, or by the `sar_msg_ref_num`, +- `sar_total_segments` and `sar_segment_seqnum` TLVs, reassemble into one `sms` alike. A PDU carrying ++ `sar_total_segments` and `sar_segment_seqnum` TLVs, reassemble into one message alike. A PDU carrying + both is read from the header. The two reference numbers are separate counters: the same number in + each is two messages. +-- **Answered on arrival.** Each segment was answered as it landed, before you see the message: +- [Server in depth](#server-in-depth). +-- **Unanswered messages.** While a session holds 1000 messages you have not called `sendResp()` on, +- or 64 MiB of them counted the way `maxOctets` counts segments, every new message and segment is +- refused so the peer retries it: `ESME_RTHROTTLED` on a submission, `ESME_RX_T_APPN` on a delivery. +- No `sms` fires. Reaching the bound logs one `warn`, and the first message accepted once both are +- down to half one `info`. A message left five minutes is dropped from the count with a `warn`; a +- later `sendResp()` still answers it. None of the three is an option. ++- **Answered on arrival.** Each segment was answered as it landed, before the handler sees the ++ message: [Server in depth](#server-in-depth). ++- **Handlers still running.** While 1000 `onSms` handlers have not settled, every new message and ++ segment is refused so the peer retries it: `ESME_RTHROTTLED` on a submission, `ESME_RX_T_APPN` on ++ a delivery. Reaching the bound logs one `warn`, and the first message accepted once it is down to ++ half one `info`. A handler still running after five minutes is dropped from the count with a ++ `warn`. Neither number is an option. ++- **No handler.** A session with no `onSms` refuses every message with `ESME_RX_P_APPN`, so the ++ peer is not left waiting for an answer that never comes. + - **Where the body is.** A body in the `message_payload` TLV, SMPP's way of carrying up to 64 KB and + the only place a `data_sm` has, reads exactly like one in `short_message`, concatenated messages + and receipts included. A PDU filling both is read from `short_message`. +-- **`data_sm`.** A client reads an inbound `data_sm` as a delivery: a message arrives as `sms`, a +- receipt as `dlr`. A `server()` session reads it as a submission and always emits `sms`. Either way ++- **`data_sm`.** A client reads an inbound `data_sm` as a delivery: a message goes to `onSms`, a ++ receipt to `dlr`. A `server()` session reads it as a submission and always hands it to `onSms`. Either way + it is answered `data_sm_resp`. + - **Flash.** `sms.flash` is true where `data_coding` carries GSM 03.38 message class 0, in every + coding group that carries one: `0x10`, `0x18`, `0x50` and `0xF0` alike. Classes 1 to 3 name where +@@ -501,23 +494,20 @@ even where the SMSC took some of its segments; their receipts still arrive as `d + + ## Server in depth + +-**Multipart is answered on arrival.** Each segment is answered as it lands, because a relaying SMSC +-will not send the next until the last is answered. The answer is `ESME_ROK`, unless the segment +-numbers itself into no message this session can join, which refuses it, or the reassembly buffer or +-the unanswered messages are at their bound, which asks the SMSC to keep it and try again. +-`sms.answeredOnArrival` says whether the message you hold was answered that way; a segment count +-cannot, since a peer may number a message one part of one. ++**Every message is answered on arrival.** Each segment is answered `ESME_ROK` as it lands, because ++a relaying SMSC will not send the next until the last is answered, and a whole message the same way ++for the same reason. The exceptions refuse it: a segment that numbers itself into no message this ++session can join, or a reassembly buffer or the running handlers at their bound, which asks the SMSC ++to keep it and try again. + +-- The id was fixed with the first segment, so `sendResp()` there puts nothing on the wire and +- releases the message, and returns `err` for an `smsId` or a refusing `status`. + - `sms.smsId` is the base. `sendDlr()` names `-1`, `-2` and so on: the ids the + `submit_sm` responses carried. + - A `deliver_sm` is answered with no id at all, since SMPP marks that field unused, so an inbound + message's base is a handle of your own only. + +-**Refusing a request** for a reason in the request rather than the message (a full queue, an unknown +-recipient, an unauthorised sender) has to land before a segment is answered. `onRequest` runs on +-every request a bound peer sends, before reassembly and before the `sms` event: ++**Refusing a request** (a full queue, an unknown recipient, an unauthorised sender) has to land ++before the library answers it. `onRequest` runs on every request a bound peer sends, before ++reassembly and before `onSms`: + + ```javascript + import { isCommand, server } from '@larvit/smpp'; +@@ -545,7 +535,8 @@ if (err) throw err; + `authenticate` itself. + - A hook that throws or rejects reaches `sessionError`, and nothing is written for that request; + the peer's own response timeout settles it. `authenticate` fails the same way, leaving the bind +- unanswered. ++ unanswered. An `onSms` handler that fails reaches `sessionError` too, but its message was ++ answered before it ran. + - `enquire_link` and `unbind` reach the hook too, and an unanswered `enquire_link` has the peer drop + the link. Guard on the command name, as above, and a failing hook costs only its own request. + - A `Session` you construct yourself takes the same hook as a session option, and that is where a +@@ -668,7 +659,7 @@ if (isCommand(pduObj, 'submit_sm')) { + | Receipts | `dlrFromPdu`, `parseReceipt`, `receiptCodes` | + | Time and ids | `smppDate`, `smppTime`, `uuidv7` | + | Spec tables | `cmds`, `consts`, `encodings`, `errors`, `tlvs`, `types`, the `cmdsById`, `constsById`, `errorsById` and `tlvsById` maps, and all of them grouped as `defs`. `isCommandName`, `isErrorName`, `isEncodingName`, `isTlvName`, `commandNameById` and `errorNameById` narrow a value into them. | +-| Types | Every option, result, event payload and table entry has a named type: `ClientOptions`, `ServerOptions`, `SendSmsOptions`, `SendSmsResult`, `Sms`, `Dlr`, `MessageDlr`, `Receipt`, `PduObject`, `PduHeader`, `SmppLog`, `Result` and the rest in `dist/index.d.ts`. | ++| Types | Every option, result, event payload and table entry has a named type: `ClientOptions`, `ServerOptions`, `SendSmsOptions`, `SendSmsResult`, `SmsHandler`, `Sms`, `Dlr`, `MessageDlr`, `Receipt`, `PduObject`, `PduHeader`, `SmppLog`, `Result` and the rest in `dist/index.d.ts`. | + + ## What changed per release + +diff --git a/benchmarks/smsc-sink.ts b/benchmarks/smsc-sink.ts +index 324c11e..02a2e5d 100644 +--- a/benchmarks/smsc-sink.ts ++++ b/benchmarks/smsc-sink.ts +@@ -5,22 +5,14 @@ import { server } from '../src/server.ts'; + * library's own ceiling rather than an SMSC's storage. Prints the bound port on stdout, then waits. + */ + const port = Number(process.env.PORT ?? 0); +-const { err, server: smpp } = await server({ port }); ++let answered = 0; ++const { err, server: smpp } = await server({ onSms: () => { answered++; }, port }); + + if (err) { + process.stderr.write(`sink failed to listen: ${err.message}\n`); + process.exit(1); + } + +-let answered = 0; +- +-smpp.on('session', session => { +- session.on('sms', async sms => { +- answered++; +- await sms.sendResp(); +- }); +-}); +- + smpp.on('serverError', reason => { + process.stderr.write(`sink serverError: ${reason.message}\n`); + }); +diff --git a/docs/decisions.md b/docs/decisions.md +index c245097..a288851 100644 +--- a/docs/decisions.md ++++ b/docs/decisions.md +@@ -18,16 +18,14 @@ rule and an index of the titles below. + + - **Both emitters re-declare their listener methods to accept a promise.** Maintainer's call, + 2026-08-27: `EventEmitter` types every listener as void-returning, so the +- `session.on('sms', async sms => …)` README documents reads as a misused promise in any strict ++ `session.on('dlr', async dlr => …)` a consumer writes reads as a misused promise in any strict + consumer. `declare on: …` and its six siblings re-type the inherited methods to return `unknown`, + which emits nothing and needs no cast; overriding them as real methods cannot work, because the + `super.on()` call needs one. The cost is that a subclass can no longer reach those seven through + `super` — re-declaring them the same way is its way out. `unknown` rather than + `void | Promise` because a listener may return anything: `session.on('close', () => +- set.delete(session))` returns a boolean. This also settles what the drain can wait on: a listener's +- own promise would be the better completion signal, and reaching it needs `listeners()`, which +- cannot be re-declared the same way — Node types it invariantly enough that widening `void` to +- `unknown` is `TS2416`. Re-probed 2026-09-01; `sendResp()` stays the signal. ++ set.delete(session))` returns a boolean. A listener's own promise is never waited on, which is why ++ an inbound message is a handler and not an event. + + - **`PduRefusedError` is exported, and `sessionError` names it in the event's type.** Maintainer's + call, 2026-09-05, from a product review: one event carries both a PDU the peer malformed and the +@@ -234,7 +232,7 @@ rule and an index of the titles below. + coding it exactly as 00xx. Rejected: reading only the two groups the defect named, which needs an + extra test to produce a wrong answer for a class the spec puts in plain sight. Accepted: the + alphabet is read only where a class is, so 0x58 is UCS2 while 0x48 — the same alphabet with the +- class bit clear — stays ASCII, because below 0x10 SMPP's flat table contradicts 03.38 and wins ++ class bit clear — stays GSM7, because below 0x10 SMPP's flat table contradicts 03.38 and wins + (0x03 is Latin-1 there, GSM 7-bit here) and a class is the only evidence a peer below 0x80 is + spelling 03.38 at all. Send-side: `flash` + is that class, so it goes out as 0x18 beside UCS2 and 0x10 beside GSM 7-bit, while +@@ -361,7 +359,7 @@ rule and an index of the titles below. + - **An alphabet the caller named has to carry the message, and a time the format cannot express is + refused, both before a segment goes out.** Maintainer's call, 2026-09-09, from the architecture and + stability reviews of [#96](https://github.com/larvit/larvitsmpp/pull/96): `encoding: 'LATIN1'` on +- `あいう` put `42 44 46` — `"BDF"` — on the wire and returned success, `encoding: 'ASCII'` ++ `あいう` put `42 44 46` — `"BDF"` — on the wire and returned success, `encoding: 'GSM7'` + flattened every character outside 03.38 to a space, and `validityPeriod: new Date('nope')` wrote + `NaNNaNNaNNaNNaNNaNNaN00+` into the PDU. Goal 2 owns all three: bytes that do not say what the + caller asked, reported as sent. `unencodable()` is the single answer to whether an alphabet can +@@ -439,7 +437,7 @@ rule and an index of the titles below. + + - **A GSM 03.38 message declares `data_coding` 0x00, and an inbound 0x01 is still read as GSM.** + Maintainer's call, 2026-09-09: `dataCodingFor()` and `encodeBody()` both resolved an alphabet +- through `consts.ENCODING`, so `encoding: 'ASCII'` went out as 0x01 — SMPP 3.4 5.2.19's *IA5 (CCITT ++ through `consts.ENCODING`, so `encoding: 'GSM7'` went out as 0x01 — SMPP 3.4 5.2.19's *IA5 (CCITT + T.50)/ASCII* — while the codec writes GSM 03.38, where `$` is 0x02 and `@` is 0x00 against IA5's + STX and NUL. Goal 1 owns it, and this library's own reader hid it by resolving both codings to the + same codec. `dataCodingByEncoding` is the single answer to which coding an alphabet is written +@@ -456,7 +454,7 @@ rule and an index of the titles below. + researched peer means IA5 by it. The two tables agree over most of the printable range and part at + 0x00-0x09, 0x0B-0x0C, 0x0E-0x1A, 0x1C-0x1F, 0x24, 0x40, 0x5B-0x60 and 0x7B-0x7F — line feed, + carriage return and escape are common to both — which is where a peer that did mean IA5 is +- misread. Accepted with it: `consts.ENCODING` loses its `ASCII` alias and keeps `IA5`, the two ++ misread. Accepted with it: `consts.ENCODING` loses its `GSM7` alias and keeps `IA5`, the two + names 5.2.19 gives 0x01, because that alias was the only name the two tables shared at different + values and so the only one a reader could carry from the option's vocabulary into SMPP's flat + table; `constsById.ENCODING[0x01]` already read `IA5`, so nothing moves but the forward name. +@@ -508,10 +506,11 @@ rule and an index of the titles below. + Maintainer's call, 2026-08-31: without the split, an application that opens a replacement client on + `close` ends up holding two binds on one account, which goal 4 forbids. + +-- **An answer belongs to the link the message arrived on; a receipt does not.** Maintainer's call, +- 2026-09-01. Rejected: answering on the new link, which succeeds and reports `{}` for a response +- that correlates with nothing — goal 2's wrong answer. Accepted: a receipt sent after a refused +- response names an id the peer has no record of. ++- **An answer goes out on the socket the request arrived on; a receipt on whichever link is up.** ++ Maintainer's call, 2026-09-01, narrowed 2026-09-29: a response carries the request's sequence ++ number, which correlates with nothing on another link, so `IncomingRequests.handle()` drops a ++ request whose socket went while `onRequest` ran rather than answering it on the next one — goal 2's ++ wrong answer. A receipt names a message id, which the peer keeps across links. + + - **`reconnect` takes `{ minDelay, maxDelay }` to retune and `false` to turn off**, so absent means + on and there is one spelling for each. Only `client()` reconnects — a `server()` session is a +@@ -583,39 +582,36 @@ rule and an index of the titles below. + one round trip rather than one per segment. Rejected: sending each segment once the last is + answered, which a receiver waiting for the whole message before answering would deadlock. + +-- **Every segment of a concatenated message is answered as it arrives, so `sendResp()` on one is the +- application's own signal rather than the peer's answer.** Maintainer's call, 2026-09-06, from the +- Jasmin interoperability phase: Jasmin dispatches one `submit_sm` per connector at a time and will +- not send segment 2 until segment 1 is answered, so holding a group unanswered until it was whole +- deadlocked every multi-segment message against a production gateway +- ([interop-tests/findings/03-jasmin.md](../interop-tests/findings/03-jasmin.md)). Goal 1 has the answer +- a real SMSC gives — one `message_id` per `submit_sm`, immediately — so the group's id base is +- generated when it opens and each segment is answered `-`, the notation `sms-id.ts` owns +- and `DlrMerger` reads back. The id is therefore fixed by the first segment, which is why an `smsId` +- or a refusing `status` passed to `sendResp()` on such a message is an error rather than a silent +- no-op. `answeredOnArrival` is on `Sms` because nothing the application can compute says it, and the +- discriminant a reader would reach for instead is wrong. A message `sendResp()` still answers itself is +- untouched, and is where a caller-chosen id and a refusal live; `onRequest` is the escape hatch for +- an application that must refuse a PDU the `sms` event could not have shown it yet. `collect()` +- answers every segment it will not carry rather than leaving it unanswered, which is the same stall +- in miniature: the field that numbered it where the segment belongs to no group, the retry status +- where the segment's own arrival overran the octet cap, since a peer told that still holds it. Rejected: +- answering every segment but the one that completes the group, which leaves the peer holding some +- segments accepted and one refused with nothing in SMPP to retract the rest, and still cannot honour +- a caller's `smsId` on the segments already gone. Rejected: a hook that mints the id per segment, +- which asks the application to name a message it cannot read yet — what it wants is `sms.smsId` +- afterwards. Rejected: an option to keep the old behaviour, a second spelling whose only +- distinguishing feature is that it deadlocks. Accepted: a group given up on — expired, evicted, or +- dropped with the link — is traffic the peer will not send again, so each one reaches `sessionError` +- as well as the log. Rejected there: an exported `MessageLostError` carrying the group, on the +- `PduRefusedError` pattern — no `sms` ever fired for that group, so there is nothing in it the +- application could act on, and goal 8 does not buy a second exported class to make a count +- distinguishable. Accepted: a completing segment whose own answer the socket would not carry still +- reaches the application, because the message is whole and correct and the failed answer is on +- `sessionError` — a peer that re-sends after the drop is the smaller risk than dropping a message +- in hand. The answer goes out before the `sms` event either way, so a listener's own receipt can +- never precede the acceptance of the message it reports on. +- ++- **Every message is answered on arrival, and `onSms` is a handler the session waits for rather than ++ an event.** Maintainer's call, 2026-09-06, widened 2026-09-29 from concatenated messages to every ++ message. Jasmin dispatches one `submit_sm` per connector at a time and will not send segment 2 ++ until segment 1 is answered, so holding a group unanswered until it was whole deadlocked every ++ multi-segment message against a production gateway ++ ([interop-tests/findings/03-jasmin.md](../interop-tests/findings/03-jasmin.md)); goal 1 has the ++ answer a real SMSC gives — one `message_id` per `submit_sm`, immediately — so each segment is ++ answered `-` off a base generated when its group opens, the notation `sms-id.ts` owns and ++ `DlrMerger` reads back. A single-segment message used to be the application's to answer through ++ `sendResp()`, which made two contracts of one: an `answeredOnArrival` flag, a `sendResp()` that ++ wrote nothing on one kind of message and refused an `smsId` on it, and a drain that had to know ++ when the application was "done" with a message — six ways out of a hold, one of them a ++ `setImmediate`, one a listener count. Goal 8's small surface wins: one rule, the answer is the ++ library's, and the application's part is the handler. `onSms` is an option rather than an event ++ because its promise is what the session waits for on shutdown and what the running-handler bound ++ counts, and no EventEmitter listener is waited for anywhere else. A session with no handler ++ refuses every message `ESME_RX_P_APPN` rather than answering `ESME_ROK` and dropping it, since ++ work the peer has no reason to send again is not dropped (goal 2), and rather than leaving it ++ unanswered, which holds the peer's window for its timeout (goal 4). A handler that throws or ++ rejects reaches `sessionError` and nothing else: its message was answered before it ran, so there ++ is nothing left to answer on its behalf. `onRequest` is where a request is refused, since it runs ++ before the answer. Rejected: keeping the single-segment answer with the application, the two ++ contracts above. Rejected: an awaited `sms` event, where zero listeners, two listeners and a ++ rejecting one each need a rule. Accepted: an application cannot name the `message_id` a submission ++ is answered with; `sms.smsId` is a UUID v7 it stores a mapping under. Accepted: a completing ++ segment whose own answer the socket would not carry still reaches the handler, because the message ++ is whole and the failed answer is on `sessionError` — a peer that re-sends after the drop is the ++ smaller risk than dropping a message in hand. A group given up on — expired, evicted, or dropped ++ with the link — is traffic the peer will not send again, so each one reaches `sessionError` as ++ well as the log. + - **`server()` composes the application's `onRequest` after its own bind handling, and offers it + every request that handling did not answer.** Maintainer's call, 2026-09-06, from a product review + of the multipart change: `server()` filled the session's only `onRequest` slot, so the escape hatch +@@ -653,40 +649,30 @@ rule and an index of the titles below. + fall-through could be gated on it, which buys a fail-open path with state and an internal contract + no other collaborator needs. + +-- **The drain waits on the messages the application holds, and `sendResp()` is what says it is done +- with one.** Maintainer's call, 2026-09-01: waiting on the send window alone tore a server session +- down while the application was still answering a `submit_sm`, so the peer timed out and re-sent — +- the duplicate goal 2 forbids, in the direction the window already covers. No completion signal was +- added to the `sms` event: `sendResp()` is what an application already calls when it is done with a +- message, so it is the one the drain waits for. Counting every inbound request until `sendReturn()` +- answered it was rejected: an `onRequest` that deliberately answers nothing would then cost a full +- `shutdownTimeout` on every close. The response reaching the wire ends the wait, so a `sendResp()` +- the library refused or the socket would not carry leaves `close()` still reporting the message the +- peer is owed. +- +-- **The drain's wait on the application ignores `shutdownTimeout: 0`.** Waiting forever is safe for +- the peer, whose every request is bounded by `responseTimeout` unless the caller set that to 0 as +- well, and unsafe for the application, which nothing bounds — `close()` is what you reach for when +- the 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 +- 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 +- refused.** A bound the application cannot raise is the point: an application that answers nothing +- would otherwise hold ever more messages, which goal 4 forbids. Reassembly's `maxOctets` is an +- option because it bounds what the peer sends; this bounds what the application leaves unanswered. +- Maintainer's call, 2026-09-26. Refusing leaves the message with the peer, which will send it again +- (goal 2). Rejected: dropping the oldest to make room, which frees nothing while the application +- still holds its `Sms`, and stops the drain waiting for a message the peer is owed. Rejected: +- pausing the socket, which also stalls every answer and `enquire_link` on the link. Reaching the +- bound shows only in the log (goal 8): an event or a public count would be surface for what the +- application already knows, since it is the one not answering. A message held past its timeout is +- still dropped, so `close()` can report fewer unanswered than there were — accepted, because the +- alternative is holding what nothing will answer. +- ++- **The drain waits on the `onSms` handlers still running, then on the requests on the wire, and a ++ receipt is let past it.** Maintainer's call, 2026-09-01, restated 2026-09-29: waiting on the send ++ window alone tore a server session down while the application was still working on a message, and ++ a receipt it then sent was refused as a send after `close()`. The handler's promise is the one ++ signal of "done", so nothing on `Sms` says it; `sendDlr()` takes the `receipt` lane, which a ++ shutdown never refuses, because a receipt reports on a message this session took and the window ++ wait still bounds it. `shutdownTimeout: 0` waits for the handlers as it waits for the requests: ++ the five-minute handler deadline bounds that half on its own, so no fallback to `responseTimeout` ++ is needed. Rejected: counting every inbound request until `sendReturn()` answered it, since an ++ `onRequest` that deliberately answers nothing would cost a full `shutdownTimeout` on every close. ++ ++- **Running handlers are capped on a constant, and a message past the cap is refused.** A bound the ++ application cannot raise is the point: a handler that never settles would otherwise pile up ++ without limit, which goal 4 forbids. Reassembly's `maxOctets` is an option because it bounds what ++ the peer sends; this bounds what the application has not finished. Maintainer's call, 2026-09-26. ++ Refusing leaves the message with the peer, which will send it again (goal 2). Rejected: pausing ++ the socket, which also stalls every answer and `enquire_link` on the link. Reaching the bound ++ shows only in the log (goal 8): an event or a public count would be surface for what the ++ application already knows, since its handlers are the ones not finishing. A handler past its ++ deadline is dropped from the count, so `close()` can report fewer running than there are — ++ accepted, because the alternative is waiting on what nothing will end. + - **A store at its bound answers `ESME_RTHROTTLED` to a submission and `ESME_RX_T_APPN` to a + delivery, a `data_sm` by whichever it stands in for.** Maintainer's call, 2026-09-26, for +- reassembly and held messages alike, so "keep it and retry" has one spelling per direction. ++ reassembly and running handlers alike, so "keep it and retry" has one spelling per direction. + `ESME_RTHROTTLED` asks the sender to slow down, which is what the peer outrunning us needs, and + operators send it (Vonage, LINK Mobility, Route Mobile, Jasmin), so clients built against them + meet it (goal 1). Rejected: `ESME_RMSGQFUL`, which names an exhausted queue and no rate. +@@ -750,14 +736,28 @@ rule and an index of the titles below. + an abort while held for a link already gives. The drain half needs nothing: `close({ signal })` already hands the signal to + `window.idle()`, and `unbind()` taking none is the shape README states. + +-- **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. +- `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. ++- **One owner decides whether a link can carry a request, and `bound()` is what makes it one.** ++ Maintainer's call, 2026-09-01, extended 2026-09-28 and 2026-09-29; goal 1, since a send on a link ++ not yet bound comes back `ESME_RINVBNDSTS`. `LinkLife` starts at `binding` on every socket and ++ `Session.bound()` opens it, so a hand-wired session and `server()`'s carry nothing until the bind ++ they record; `LinkLife` is told what happened and never reads back into the session, and 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. `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. ++ ++- **Every request leaves through one `request()`, in one of three lanes.** Maintainer's call, ++ 2026-09-29, from the second comprehension round: `request`, `requestPastDrain` and ++ `requestOnCurrentLink` each skipped a different subset of the checks, and a reader had to diff ++ them. `message` takes every check; `receipt` is the same less the shutdown refusal; `link` — a ++ bind, an unbind, a keepalive — goes out now on the socket as it is, since a bind is what makes the ++ link carry anything, an unbind follows a drain that may have left the window full, and a keepalive ++ behind a full window would let the idle timer drop a healthy link. The lane is chosen by the ++ command name in `Session.send()` and by `sendDlr()` for a receipt; nothing else names one. A ++ request on a bound link with a free slot reaches the socket before `request()` returns, so a ++ handler's `void sms.sendDlr()` is in the window by the time the handler's promise settles — a ++ mechanical fact, where the old `setImmediate` was a timing contract. + + + ## Internals and tests +@@ -795,11 +795,14 @@ rule and an index of the titles below. + fail on every developer machine, and a committed key leaks in a public repository. Valid while the + dev image has no openssl. + +-- **`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*`, +- `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. ++- **`src/` is grouped by what a reader is looking for: the session's parts, the messages, the ++ wire.** Maintainer's call, 2026-09-29, reversing the 2026-09-06 review's "stay flat until a module ++ has to move": that decision was valid while the file map was what a reader navigated by, and two ++ scoring rounds reported that the map had become AGENTS.md rather than the tree. `session/` holds ++ what only `Session` composes, `messages/` what a message or a receipt is made of on either way, ++ `wire/` the codec and framing; the eight files at the top are the three public entry points, the ++ session, the defaults and the three shared shapes. Rejected: a `link/` beside `session/`, which ++ would split the session's parts on a line nothing in the code draws. + + - **`test/` stays flat too, and a file there is named for the question it answers rather than for the + module it covers.** Architecture review, 2026-09-08, at 18 test files: what keeps that count honest +diff --git a/eslint.config.js b/eslint.config.js +index 755c9c0..e3d4a6a 100644 +--- a/eslint.config.js ++++ b/eslint.config.js +@@ -40,7 +40,7 @@ export default tseslint.config( + }, + { + // ESLint counts every ?. and ?? in dlrFromPdu as a branch; the 19 is 26 lines of flat field resolution. +- files: ['src/dlr.ts'], ++ files: ['src/messages/dlr.ts'], + rules: { complexity: ['error', 19] }, + }, + { +diff --git a/interop-tests/cloudhopper.test.ts b/interop-tests/cloudhopper.test.ts +index dca5b5b..434eebf 100644 +--- a/interop-tests/cloudhopper.test.ts ++++ b/interop-tests/cloudhopper.test.ts +@@ -1,9 +1,12 @@ + import assert from 'node:assert/strict'; + import { readFileSync } from 'node:fs'; + import test, { after, describe } from 'node:test'; ++import type { OnRequest } from '../src/session/session-options.ts'; + import type { Session } from '../src/session.ts'; +-import type { Sms } from '../src/sms.ts'; ++import type { Sms } from '../src/messages/sms.ts'; + import type { SmppServer } from '../src/server.ts'; ++import { isCommand } from '../src/wire/pdu.ts'; ++import { paramText } from '../src/defs/types.ts'; + import { server } from '../src/server.ts'; + + const CLOUDHOPPER_HOST = process.env.CLOUDHOPPER_HOST ?? 'cloudhopper:8080'; +@@ -38,26 +41,40 @@ async function driver(path: string, params: Record = {}): Promis + return response.json() as Promise; + } + +-const manualTexts = new Set(); ++/** How long a submit carrying this text is held before it is answered, where not SLOW_DELAY_MS. */ ++const holdMs = new Map(); ++const refusedTexts = new Set(); + const allSms: { session: Session; sms: Sms }[] = []; + +-function attach(session: Session): void { +- session.on('sms', sms => { +- allSms.push({ session, sms }); ++// The slow server this phase's window scenarios need: every ordinary submit is held for ++// SLOW_DELAY_MS before being answered, so a burst genuinely presses on a small window. ++const holdOrRefuse: OnRequest = async (session, pduObj) => { ++ if (!isCommand(pduObj, 'submit_sm')) return false; + +- if (manualTexts.has(sms.message)) return; ++ const text = paramText(pduObj.params.short_message); + +- // The slow server this phase's window scenarios need: every ordinary submit is held for +- // SLOW_DELAY_MS before being answered, so a burst genuinely presses on a small window. +- void delay(SLOW_DELAY_MS).then(() => sms.sendResp()); +- }); +-} ++ if (refusedTexts.has(text)) { ++ await session.sendReturn(pduObj, 'ESME_RMSGQFUL'); ++ ++ return true; ++ } ++ ++ await delay(holdMs.get(text) ?? SLOW_DELAY_MS); + +-const { err: serverErr, server: smpp } = await server({ authenticate: () => true, idleTimeout: 40_000, port: SMPP_PORT }); ++ return false; ++}; ++ ++const serverOptions = { ++ authenticate: () => true, ++ idleTimeout: 40_000, ++ onRequest: holdOrRefuse, ++ onSms: (sms: Sms) => { allSms.push({ session: sms.session, sms }); }, ++}; ++ ++const { err: serverErr, server: smpp } = await server({ ...serverOptions, port: SMPP_PORT }); + + assert.equal(serverErr, undefined); + assert.ok(smpp); +-smpp.on('session', attach); + + const key = readFileSync('/shared-certs/server.key'); + const cert = readFileSync('/shared-certs/server.crt'); +@@ -65,15 +82,13 @@ const cert = readFileSync('/shared-certs/server.crt'); + // findings/05-java-clients.md, Peer quirks. Capped here for S10 only; the plain listener above is + // unrestricted. + const { err: tlsServerErr, server: tlsSmpp } = await server({ +- authenticate: () => true, +- idleTimeout: 40_000, ++ ...serverOptions, + port: TLS_PORT, + tls: { cert, key, maxVersion: 'TLSv1.2' }, + }); + + assert.equal(tlsServerErr, undefined); + assert.ok(tlsSmpp); +-tlsSmpp.on('session', attach); + + after(async () => { + await smpp.close(); +@@ -139,20 +154,15 @@ describe('S5 - request expiry shorter than the handler delay (target 11)', () => + + const text = 'expiry-probe'; + +- manualTexts.add(text); ++ // Held well past requestExpiryTimeout (100ms) before answering, so the peer's own window ++ // monitor gives up on it first - recorded, not asserted against, since that is the peer's call. ++ holdMs.set(text, 1000); + +- const submitted = driver('/submit', { session: 'expiry', text, timeoutMs: '5000' }); ++ const result = await driver('/submit', { session: 'expiry', text, timeoutMs: '5000' }); + const sms = await waitFor(() => allSms.find(entry => entry.sms.message === text)?.sms); + + assert.ok(sms); + +- // Held well past requestExpiryTimeout (100ms) before answering, so the peer's own window +- // monitor gives up on it first - recorded, not asserted against, since that is the peer's call. +- await delay(1000); +- await sms.sendResp(); +- +- const result = await submitted; +- + assert.equal(result.ok, false); + // Cloudhopper's window monitor gives up on the expired slot with a RecoverablePduException, + // not the SmppTimeoutException its own per-call timeoutMs would throw - the two expiries are +@@ -189,7 +199,7 @@ describe('S10 - Cloudhopper SSL client against our server({ tls })', () => { + }); + + describe('a refusing status is surfaced back to Cloudhopper', () => { +- test('sms.sendResp({ status: "ESME_RMSGQFUL" }) reaches Cloudhopper in the response', async () => { ++ test('sendReturn(pduObj, "ESME_RMSGQFUL") from onRequest reaches Cloudhopper in the response', async () => { + const bind = await driver('/bind', { password: 'chpw', session: 'refuse', systemId: 'ch-refuse' }); + + assert.equal(bind.ok, true); +@@ -197,18 +207,13 @@ describe('a refusing status is surfaced back to Cloudhopper', () => { + + const text = 'ch-refuse-me'; + +- manualTexts.add(text); +- +- const submitted = driver('/submit', { session: 'refuse', text, timeoutMs: '5000' }); +- const sms = await waitFor(() => allSms.find(entry => entry.sms.message === text)?.sms); +- +- assert.ok(sms); +- await sms.sendResp({ status: 'ESME_RMSGQFUL' }); ++ refusedTexts.add(text); + +- const result = await submitted; ++ const result = await driver('/submit', { session: 'refuse', text, timeoutMs: '5000' }); + + assert.equal(result.ok, true); + assert.equal(result.commandStatus, 0x00000014); ++ assert.equal(allSms.some(entry => entry.sms.message === text), false); + await driver('/unbind', { session: 'refuse' }); + }); + }); +diff --git a/interop-tests/compose.dumbclient.yaml b/interop-tests/compose.dumbclient.yaml +index e6c4278..ba6c83e 100644 +--- a/interop-tests/compose.dumbclient.yaml ++++ b/interop-tests/compose.dumbclient.yaml +@@ -32,7 +32,7 @@ services: + dumbclient-netns: + condition: service_started + +- # S9's comparison run: window below maxHeldMessages (1000, session-options.ts), where nothing ++ # S9's comparison run: window below maxRunningHandlers (1000, src/defaults.ts), where nothing + # should ever be throttled - see findings/07-load.md. + dumbclient-w500: + build: ./interop-tests/peers/dumbclient +diff --git a/interop-tests/dumbclient.test.ts b/interop-tests/dumbclient.test.ts +index 81bc9ff..ecf5352 100644 +--- a/interop-tests/dumbclient.test.ts ++++ b/interop-tests/dumbclient.test.ts +@@ -1,13 +1,13 @@ + import assert from 'node:assert/strict'; + import test, { after, describe } from 'node:test'; + import type { Session } from '../src/session.ts'; +-import type { Sms } from '../src/sms.ts'; ++import type { Sms } from '../src/messages/sms.ts'; + import type { LogMethod, SmppLog } from '../src/log.ts'; + import { server } from '../src/server.ts'; + + const SMPP_PORT = Number(process.env.SMPP_PORT ?? '2775'); +-/** Slower than every scenario's submission rate (2000/s for the window runs), so a real backlog +- * presses on the configured window instead of draining as fast as it fills - see findings/07-load.md. */ ++/** Slower than every scenario's submission rate (2000/s for the window runs), so a real backlog of ++ * running handlers builds instead of draining as fast as it fills - see findings/07-load.md. */ + const SLOW_HANDLER_DELAY_MS = 2; + + function delay(ms: number): Promise { +@@ -37,8 +37,6 @@ function scenarioOf(session: Session): string { + } + + type ScenarioStats = { +- answerOrder: number[]; +- answered: number; + arrived: number; + // Set from a 'close' listener attached the moment the session is first seen (smpp.on('session')), + // never lazily inside a test body - a session can close well before a test gets around to +@@ -46,9 +44,10 @@ type ScenarioStats = { + // replays an event to a listener added after it fired. + closed: boolean; + duplicateIds: number; ++ handled: number; ++ handledOrder: number[]; + ids: Set; +- peakOutstanding: number; +- unansweredErrors: number; ++ peakRunning: number; + }; + + const stats = new Map(); +@@ -59,14 +58,13 @@ function statsFor(name: string): ScenarioStats { + if (existing) return existing; + + const created: ScenarioStats = { +- answerOrder: [], +- answered: 0, + arrived: 0, + closed: false, + duplicateIds: 0, ++ handled: 0, ++ handledOrder: [], + ids: new Set(), +- peakOutstanding: 0, +- unansweredErrors: 0, ++ peakRunning: 0, + }; + + stats.set(name, created); +@@ -101,63 +99,66 @@ const memTimer = setInterval(() => { + + memTimer.unref(); + +-const { err, server: smpp } = await server({ +- authenticate: ({ systemId }) => ({ userData: { systemId } satisfies ScenarioUserData }), +- idleTimeout: 40_000, +- log, +- port: SMPP_PORT, +-}); +- +-assert.equal(err, undefined); +-assert.ok(smpp); +- +-const serverErrors: Error[] = []; +- +-smpp.on('serverError', serverError => { serverErrors.push(serverError); }); +- + const sessionByScenario = new Map(); + const slowQueues = new Map>(); + +-function answered(session: Session, arrivalIndex: number, result: { err?: Error }): void { ++function handled(session: Session, arrivalIndex: number): void { + const s = statsFor(scenarioOf(session)); + +- s.answered++; +- s.answerOrder.push(arrivalIndex); +- if (result.err) s.unansweredErrors++; ++ s.handled++; ++ s.handledOrder.push(arrivalIndex); + } + +-function slowRespond(session: Session, sms: Sms, arrivalIndex: number): void { +- const chain = (slowQueues.get(session) ?? Promise.resolve()) ++/** One message at a time per session, SLOW_HANDLER_DELAY_MS each, so the handlers behind it stay running. */ ++function slowHandle(sms: Sms, arrivalIndex: number): Promise { ++ const chain = (slowQueues.get(sms.session) ?? Promise.resolve()) + .then(async () => { await delay(SLOW_HANDLER_DELAY_MS); }) +- .then(async () => { answered(session, arrivalIndex, await sms.sendResp()); }); ++ .then(() => { handled(sms.session, arrivalIndex); }); + +- slowQueues.set(session, chain); +-} ++ slowQueues.set(sms.session, chain); + +-function fastRespond(session: Session, sms: Sms, arrivalIndex: number): void { +- void sms.sendResp().then(result => { answered(session, arrivalIndex, result); }); ++ return chain; + } + +-smpp.on('session', session => { +- // Attached now, not lazily in a test body - see the comment on ScenarioStats.closed. +- session.on('close', () => { statsFor(scenarioOf(session)).closed = true; }); ++function onSms(sms: Sms): Promise | undefined { ++ const { session } = sms; ++ const name = scenarioOf(session); + +- session.on('sms', sms => { +- const name = scenarioOf(session); ++ sessionByScenario.set(name, session); + +- sessionByScenario.set(name, session); ++ const s = statsFor(name); ++ const arrivalIndex = s.arrived; + +- const s = statsFor(name); +- const arrivalIndex = s.arrived; ++ s.arrived++; ++ if (s.ids.has(sms.smsId)) s.duplicateIds++; ++ else s.ids.add(sms.smsId); ++ s.peakRunning = Math.max(s.peakRunning, s.arrived - s.handled); + +- s.arrived++; +- if (s.ids.has(sms.smsId)) s.duplicateIds++; +- else s.ids.add(sms.smsId); +- s.peakOutstanding = Math.max(s.peakOutstanding, s.arrived - s.answered); ++ if (name === 'dumb-w2000') return slowHandle(sms, arrivalIndex); + +- if (name === 'dumb-w500' || name === 'dumb-w2000') slowRespond(session, sms, arrivalIndex); +- else fastRespond(session, sms, arrivalIndex); +- }); ++ handled(session, arrivalIndex); ++ ++ return undefined; ++} ++ ++const { err, server: smpp } = await server({ ++ authenticate: ({ systemId }) => ({ userData: { systemId } satisfies ScenarioUserData }), ++ idleTimeout: 40_000, ++ log, ++ onSms, ++ port: SMPP_PORT, ++}); ++ ++assert.equal(err, undefined); ++assert.ok(smpp); ++ ++const serverErrors: Error[] = []; ++ ++smpp.on('serverError', serverError => { serverErrors.push(serverError); }); ++ ++smpp.on('session', session => { ++ // Attached now, not lazily in a test body - see the comment on ScenarioStats.closed. ++ session.on('close', () => { statsFor(scenarioOf(session)).closed = true; }); + }); + + function memShape(): string { +@@ -196,7 +197,7 @@ after(async () => { + clearInterval(memTimer); + report(`memory shape: ${memShape()}`); + for (const [name, s] of stats) { +- report(`${name}: arrived=${String(s.arrived)} answered=${String(s.answered)} duplicateIds=${String(s.duplicateIds)} peakOutstanding=${String(s.peakOutstanding)} unansweredErrors=${String(s.unansweredErrors)}`); ++ report(`${name}: arrived=${String(s.arrived)} handled=${String(s.handled)} duplicateIds=${String(s.duplicateIds)} peakRunning=${String(s.peakRunning)}`); + } + + await smpp.close(); +@@ -205,22 +206,24 @@ after(async () => { + }); + + // S9 (target 11) and the backpressure-at-server scenario: window 2000 at a high rate against a +-// handler slowed enough to build a real backlog. window500 is the same shape with a window below +-// maxHeldMessages (1000, session-options.ts defaults.maxHeldMessages), the bound past which a +-// peer's window is answered ESME_RTHROTTLED. smpp-dumb-client counts a throttled message as sent +-// and never resends it, so window 2000 accounts for 20,000 as answered plus throttled. +-const throttleMessage = 'session - unanswered messages at their bound, asking the peer to retry'; +- +-// window500's peak (<=500) and the soak's never reach the 1000 default, so every refusal is +-// necessarily from the w2000 session - the runs share one server and one log. ++// handler slowed enough to build a real backlog of handlers still running. maxRunningHandlers ++// (1000, defaults.ts bounds.maxRunningHandlers) is the bound past which a message is answered ++// ESME_RTHROTTLED. smpp-dumb-client counts a throttled message as sent and never resends it, so ++// window 2000 accounts for 20,000 as handled plus throttled. window500 is the same run against a ++// handler that finishes at once: every message is answered on arrival, so the peer's window bounds ++// nothing here, and only the handler's own pace decides whether the bound is reached. ++const throttleMessage = 'session - handlers at their bound, asking the peer to retry'; ++ ++// Only w2000's handler is slow, so every refusal is necessarily from the w2000 session - the runs ++// share one server and one log, and w500 arriving whole is what proves it. + function throttled(name: string): number { + return name === 'dumb-w2000' ? logEntries.filter(entry => entry.message === throttleMessage).length : 0; + } + +-describe('S9 - bounded window against a slowed handler', () => { ++describe('S9 - bounded handler backlog against a slowed handler', () => { + for (const name of ['dumb-w500', 'dumb-w2000'] as const) { +- test(`${name}: every message answered or throttled exactly once, ordering holds`, async () => { +- const done = await waitFor(() => (statsFor(name).answered + throttled(name) >= 20_000 ? true : undefined), 180_000); ++ test(`${name}: every message handled or throttled exactly once, ordering holds`, async () => { ++ const done = await waitFor(() => (statsFor(name).handled + throttled(name) >= 20_000 ? true : undefined), 180_000); + + assert.ok(done, `${name} did not account for 20000 messages within budget`); + +@@ -228,18 +231,17 @@ describe('S9 - bounded window against a slowed handler', () => { + const expectedCount = 20_000 - throttled(name); + + assert.equal(s.arrived, expectedCount); +- assert.equal(s.answered, expectedCount); ++ assert.equal(s.handled, expectedCount); + assert.equal(s.duplicateIds, 0); +- assert.equal(s.unansweredErrors, 0); + assert.equal(s.ids.size, expectedCount); +- assert.ok(isSorted(s.answerOrder), `${name} answered out of arrival order`); ++ assert.ok(isSorted(s.handledOrder), `${name} handled out of arrival order`); + }); + } + +- test('window 2000 pressed past maxHeldMessages (1000): the peer is throttled, window500 never is', () => { ++ test('window 2000 pressed past maxRunningHandlers (1000): the peer is throttled, the fast handler at window 500 never is', () => { + assert.ok(throttled('dumb-w2000') > 0, 'expected at least one ESME_RTHROTTLED under window 2000'); +- assert.ok(statsFor('dumb-w2000').peakOutstanding <= 1000); +- assert.equal(statsFor('dumb-w500').peakOutstanding <= 500, true); ++ assert.ok(statsFor('dumb-w2000').peakRunning <= 1000); ++ assert.equal(statsFor('dumb-w500').arrived, 20_000); + }); + + test('memory after the backlog drains back down is close to before either window run started', async () => { +@@ -280,24 +282,24 @@ describe('S6 - idle peer, no enquire_link at all', () => { + assert.ok(logEntries.some(entry => entry.message === 'linkTimers - closing an idle peer')); + // teardown() (session.ts) is a raw close, not an unbind exchange - nothing further is on the + // wire for this session, which the capture's histogram (findings/07-load.md) confirms. +- assert.equal(statsFor('dumb-idle').answered, 1); ++ assert.equal(statsFor('dumb-idle').handled, 1); + }); + }); + + // The long soak: the longest run the time-box allows, fast handler, watched for anything that +-// grows without bound (held messages, listeners, memory). Bounded by wall-clock, and asserting that +-// arrived/answered stay in lockstep. ++// grows without bound (running handlers, listeners, memory). Bounded by wall-clock, and asserting ++// that arrived/handled stay in lockstep. + describe('Long soak', () => { + const SOAK_DURATION_MS = 300_000; + +- test('the longest run the time-box allows: every arrival answered, nothing duplicated, memory does not grow without bound', async () => { ++ test('the longest run the time-box allows: every arrival handled, nothing duplicated, memory does not grow without bound', async () => { + await delay(SOAK_DURATION_MS); + +- // One more turn for a response mid-flight when the clock ran out to land, not a target count. ++ // One more turn for a message mid-flight when the clock ran out to land, not a target count. + await waitFor(() => { + const s = statsFor('dumb-soak'); + +- return s.arrived === s.answered ? true : undefined; ++ return s.arrived === s.handled ? true : undefined; + }, 5000); + + const s = statsFor('dumb-soak'); +@@ -305,9 +307,8 @@ describe('Long soak', () => { + report(`soak reached: arrived=${String(s.arrived)} over ${String(SOAK_DURATION_MS / 1000)}s`); + + assert.ok(s.arrived > 0, 'dumb-soak never submitted anything'); +- assert.equal(s.answered, s.arrived); ++ assert.equal(s.handled, s.arrived); + assert.equal(s.duplicateIds, 0); +- assert.equal(s.unansweredErrors, 0); + + const session = sessionByScenario.get('dumb-soak'); + +diff --git a/interop-tests/jasmin.test.ts b/interop-tests/jasmin.test.ts +index c6c9b92..684c1cb 100644 +--- a/interop-tests/jasmin.test.ts ++++ b/interop-tests/jasmin.test.ts +@@ -1,18 +1,18 @@ + import assert from 'node:assert/strict'; + import http from 'node:http'; + import test, { after, describe } from 'node:test'; +-import type { Dlr } from '../src/dlr.ts'; ++import type { Dlr } from '../src/messages/dlr.ts'; + import type { EncodingName } from '../src/defs/encodings.ts'; +-import type { PduObject } from '../src/pdu.ts'; ++import type { PduObject } from '../src/wire/pdu.ts'; + import type { Session } from '../src/session.ts'; +-import type { Sms } from '../src/sms.ts'; +-import { ConcatReference } from '../src/udh.ts'; ++import type { Sms } from '../src/messages/sms.ts'; ++import { ConcatReference } from '../src/messages/udh.ts'; + import { client } from '../src/client.ts'; + import { closeAfter } from '../test/teardown.ts'; + import { paramText } from '../src/defs/types.ts'; + import { server } from '../src/server.ts'; +-import { encodeMessage, splitMessage } from '../src/message.ts'; +-import { submitSmParams } from '../src/send-sms.ts'; ++import { encodeMessage, splitMessage } from '../src/messages/message.ts'; ++import { submitSmParams } from '../src/messages/send-sms.ts'; + + const PEER_HOST = process.env.PEER_HOST ?? 'jasmin'; + const PEER_PORT = Number(process.env.PEER_PORT ?? '2775'); +@@ -86,6 +86,15 @@ const { err: upstreamErr, server: upstream } = await server({ + // was already in flight for Jasmin's own requeue_delay (120s default) before it retries - far past + // any per-test wait budget here - so this is generous specifically to never be the trigger. + idleTimeout: 300_000, ++ onSms: async sms => { ++ const variant = (sms.session.userData as { variant?: UpstreamVariant } | undefined)?.variant; ++ ++ if (variant) upstreamSms.push({ sms, variant }); ++ if (!sms.dlr) return; ++ ++ await delay(150); ++ await sms.sendDlr('DELIVERED'); ++ }, + port: UPSTREAM_PORT, + }); + +@@ -97,7 +106,7 @@ const upstreamServer = upstream; + upstreamServer.on('session', session => { + // `session` fires on raw connect, before authenticate() has run - session.userData is not set + // yet, so the map is populated off the bind PDU itself (like kannel.test.ts's bindPdus), not off +- // userData; userData is only read later, from 'sms', where authenticate() has long since run. ++ // userData; userData is only read later, from onSms, where authenticate() has long since run. + session.on('incomingPduObj', pduObj => { + if (!pduObj.cmdName.startsWith('bind_')) return; + +@@ -105,21 +114,6 @@ upstreamServer.on('session', session => { + + if (variant) upstreamSessions.set(variant, session); + }); +- +- session.on('sms', sms => { +- const variant = (session.userData as { variant?: UpstreamVariant } | undefined)?.variant; +- +- if (variant) upstreamSms.push({ sms, variant }); +- +- void (async () => { +- await sms.sendResp(); +- +- if (sms.dlr) { +- await delay(150); +- await sms.sendDlr('DELIVERED'); +- } +- })(); +- }); + }); + + async function waitForUpstreamSession(variant: UpstreamVariant, budget = 20_000): Promise { +@@ -227,7 +221,7 @@ async function sendUdhMo(session: Session, opts: { from: string; message: string + const multipart = segments.length > 1; + + for (const segment of segments) { +- const params = submitSmParams({ from: opts.from, message: opts.message, to: opts.to }, segment, { encoding: 'ASCII', multipart }); ++ const params = submitSmParams({ from: opts.from, message: opts.message, to: opts.to }, segment, { encoding: 'GSM7', multipart }); + const sent = await session.send({ cmdName: 'deliver_sm', params }); + + assert.equal(sent.err, undefined); +@@ -247,7 +241,7 @@ async function sendMessagePayloadMo(session: Session, opts: { from: string; mess + }, + tlvs: { + // The body is octets under the PDU's own data_coding wherever it is carried, and 0 is GSM. +- message_payload: { tagValue: encodeMessage(opts.message, 'ASCII').buffer }, ++ message_payload: { tagValue: encodeMessage(opts.message, 'GSM7').buffer }, + }, + }); + } +@@ -379,8 +373,8 @@ describe('S7 - Jasmin as the ESME against our server (HTTP send API, DLR callbac + + assert.ok(arrived, 'expected the HTTP-submitted message to arrive as submit_sm at our server()'); + +- // Our server() already answered (sendResp) and sent the DLR (sendDlr('DELIVERED')) from the +- // shared session handler above - Jasmin's own DLR pipeline should throw the HTTP callback. ++ // Our server() already answered it and sent the DLR (sendDlr('DELIVERED')) from onSms above - ++ // Jasmin's own DLR pipeline should throw the HTTP callback. + const msgidMatch = /Success "([^"]+)"/i.exec(sent.body); + const msgid = msgidMatch?.[1]; + +@@ -397,15 +391,12 @@ describe('S7 - Jasmin as the ESME against our server (HTTP send API, DLR callbac + + test('a long GSM message from our server reassembles at Jasmin (or is recorded as fragments)', async () => { + const upstreamSession = await waitForUpstreamSession('main'); +- const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' }); ++ const sms: Sms[] = []; ++ const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver', onSms: s => { sms.push(s); } }); + + assert.equal(err, undefined); + assert.ok(session); + +- const sms: Sms[] = []; +- +- session.on('sms', s => { sms.push(s); }); +- + const text = `s7-long-${'p'.repeat(300)}`; + + await sendUdhMo(upstreamSession, { from: TO, message: text, to: FROM }); +@@ -418,15 +409,12 @@ describe('S7 - Jasmin as the ESME against our server (HTTP send API, DLR callbac + + test('a UCS-2 message with 一 and an emoji from our server (or is recorded as fragments)', async () => { + const upstreamSession = await waitForUpstreamSession('main'); +- const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' }); ++ const sms: Sms[] = []; ++ const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver', onSms: s => { sms.push(s); } }); + + assert.equal(err, undefined); + assert.ok(session); + +- const sms: Sms[] = []; +- +- session.on('sms', s => { sms.push(s); }); +- + const text = `一😀${'q'.repeat(60)}`; + + await sendSarMo(upstreamSession, { from: TO, message: text, to: FROM }); +@@ -502,15 +490,12 @@ describe('C3+C7 - long MT through the fake upstream, receipts and id consistency + describe('C8 (target 3) - long MO from an upstream SMSC, SAR vs UDH segmentation', () => { + test('SAR-segmented deliver_sm from the fake upstream', async () => { + const upstreamSession = await waitForUpstreamSession('main'); +- const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' }); ++ const sms: Sms[] = []; ++ const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver', onSms: s => { sms.push(s); } }); + + assert.equal(err, undefined); + assert.ok(session); + +- const sms: Sms[] = []; +- +- session.on('sms', s => { sms.push(s); }); +- + const text = `sar-mo-${'m'.repeat(300)}`; + + await sendSarMo(upstreamSession, { from: TO, message: text, to: FROM }); +@@ -526,15 +511,12 @@ describe('C8 (target 3) - long MO from an upstream SMSC, SAR vs UDH segmentation + + test('UDH-segmented deliver_sm from the fake upstream', async () => { + const upstreamSession = await waitForUpstreamSession('main'); +- const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' }); ++ const sms: Sms[] = []; ++ const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver', onSms: s => { sms.push(s); } }); + + assert.equal(err, undefined); + assert.ok(session); + +- const sms: Sms[] = []; +- +- session.on('sms', s => { sms.push(s); }); +- + const text = `udh-mo-${'n'.repeat(300)}`; + + await sendUdhMo(upstreamSession, { from: TO, message: text, to: FROM }); +@@ -551,15 +533,12 @@ describe('C8 (target 3) - long MO from an upstream SMSC, SAR vs UDH segmentation + describe('C8 (target 2) - message_payload with sm_length 0', () => { + test('a deliver_sm carrying message_payload instead of short_message', async () => { + const upstreamSession = await waitForUpstreamSession('main'); +- const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' }); ++ const sms: Sms[] = []; ++ const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver', onSms: s => { sms.push(s); } }); + + assert.equal(err, undefined); + assert.ok(session); + +- const sms: Sms[] = []; +- +- session.on('sms', s => { sms.push(s); }); +- + const text = 'message-payload only, sm_length 0'; + const pushed = await sendMessagePayloadMo(upstreamSession, { from: TO, message: text, to: FROM }); + +diff --git a/interop-tests/jsmpp.test.ts b/interop-tests/jsmpp.test.ts +index db96513..b21a18b 100644 +--- a/interop-tests/jsmpp.test.ts ++++ b/interop-tests/jsmpp.test.ts +@@ -2,9 +2,10 @@ import assert from 'node:assert/strict'; + import net from 'node:net'; + import test, { after, describe } from 'node:test'; + import type { Session } from '../src/session.ts'; +-import type { Sms } from '../src/sms.ts'; +-import { PduRefusedError } from '../src/index.ts'; ++import type { Sms } from '../src/messages/sms.ts'; ++import { isCommand, PduRefusedError } from '../src/index.ts'; + import { bareTlvHeader, pduBytes } from '../test/raw-pdus.ts'; ++import { paramText } from '../src/defs/types.ts'; + import { server } from '../src/server.ts'; + + const JSMPP_HOST = process.env.JSMPP_HOST ?? 'jsmpp:8080'; +@@ -39,13 +40,20 @@ async function driver(path: string, params: Record = {}): Promis + const allSms: { session: Session; sms: Sms }[] = []; + const allSessionErrors: { err: Error; session: Session }[] = []; + const bindPdus: Record[] = []; +-/** Messages a test answers itself (a refusing status, or asserting on the response) - populate +- * before triggering the submit that will carry this exact text, so the global auto-ack never runs. */ +-const manualTexts = new Set(); ++/** Messages refused from onRequest - populate before triggering the submit that carries the text. */ ++const refusedTexts = new Set(); + + const { err: serverErr, server: smpp } = await server({ + authenticate: () => true, + idleTimeout: 40_000, ++ onRequest: async (session, pduObj) => { ++ if (!isCommand(pduObj, 'submit_sm') || !refusedTexts.has(paramText(pduObj.params.short_message))) return false; ++ ++ await session.sendReturn(pduObj, 'ESME_RMSGQFUL'); ++ ++ return true; ++ }, ++ onSms: sms => { allSms.push({ session: sms.session, sms }); }, + port: SMPP_PORT, + }); + +@@ -58,10 +66,6 @@ smppServer.on('session', session => { + session.on('incomingPduObj', pduObj => { + if (pduObj.cmdName.startsWith('bind_')) bindPdus.push(pduObj.params); + }); +- session.on('sms', sms => { +- allSms.push({ session, sms }); +- if (!manualTexts.has(sms.message)) void sms.sendResp(); +- }); + session.on('sessionError', err => { allSessionErrors.push({ err, session }); }); + }); + +@@ -135,7 +139,6 @@ describe('S2 - long messages in every spelling (targets 2, 3, 5)', () => { + + const sms = await waitForSms(text, 15_000); + +- assert.equal(sms.answeredOnArrival, true); + assert.equal(segments[0]?.messageId, `${sms.smsId}-1`); + assert.equal(segments[1]?.messageId, `${sms.smsId}-2`); + }); +@@ -151,7 +154,6 @@ describe('S2 - long messages in every spelling (targets 2, 3, 5)', () => { + + const sms = await waitForSms(text, 15_000); + +- assert.equal(sms.answeredOnArrival, true); + assert.equal(segments[0]?.messageId, `${sms.smsId}-1`); + assert.equal(segments[1]?.messageId, `${sms.smsId}-2`); + }); +@@ -170,7 +172,6 @@ describe('S2 - long messages in every spelling (targets 2, 3, 5)', () => { + const sms = await waitForSms(text); + + assert.equal(sms.message, text); +- assert.equal(sms.answeredOnArrival, false); + }); + + test('sar_* (target 3): one reassembled sms, each segment answered -', async () => { +@@ -186,7 +187,6 @@ describe('S2 - long messages in every spelling (targets 2, 3, 5)', () => { + + const sms = await waitForSms(text, 15_000); + +- assert.equal(sms.answeredOnArrival, true); + assert.equal(segments[0]?.messageId, `${sms.smsId}-1`); + assert.equal(segments[1]?.messageId, `${sms.smsId}-2`); + // Neither ~130-char slice ever reached the application on its own. +@@ -307,22 +307,18 @@ describe('S3 - known-but-unhandled and malformed commands (targets 1, 6)', () => + }); + + describe('a refusing status is surfaced back to jsmpp', () => { +- test('sms.sendResp({ status: "ESME_RMSGQFUL" }) reaches jsmpp as a NegativeResponseException', async () => { ++ test('sendReturn(pduObj, "ESME_RMSGQFUL") from onRequest reaches jsmpp as a NegativeResponseException', async () => { + await waitForSessions(1); + + const text = 'refuse-me'; + +- manualTexts.add(text); +- +- const submitted = driver('/submit', { encoding: 'gsm7', from: '1001', mode: 'plain', session: 'v34', text, to: '2001' }); +- const sms = await waitForSms(text); +- +- await sms.sendResp({ status: 'ESME_RMSGQFUL' }); ++ refusedTexts.add(text); + +- const result = await submitted; ++ const result = await driver('/submit', { encoding: 'gsm7', from: '1001', mode: 'plain', session: 'v34', text, to: '2001' }); + + assert.equal(result.ok, true); + assert.equal(result.refused, true); + assert.equal(result.commandStatusHex, '0x' + (0x00000014).toString(16)); ++ assert.equal(allSms.some(entry => entry.sms.message === text), false); + }); + }); +diff --git a/interop-tests/kannel.test.ts b/interop-tests/kannel.test.ts +index 0d911d0..a9e0c61 100644 +--- a/interop-tests/kannel.test.ts ++++ b/interop-tests/kannel.test.ts +@@ -2,16 +2,17 @@ import assert from 'node:assert/strict'; + import http from 'node:http'; + import test, { after, describe } from 'node:test'; + import type { MessageState } from '../src/defs/constants.ts'; +-import type { Dlr } from '../src/dlr.ts'; ++import type { Dlr } from '../src/messages/dlr.ts'; + import type { Session } from '../src/session.ts'; +-import type { Sms } from '../src/sms.ts'; +-import { ConcatReference } from '../src/udh.ts'; ++import type { Sms } from '../src/messages/sms.ts'; ++import { ConcatReference } from '../src/messages/udh.ts'; + import { consts } from '../src/defs/constants.ts'; + import { detect, encodings } from '../src/defs/encodings.ts'; ++import { isCommand } from '../src/wire/pdu.ts'; + import { paramText } from '../src/defs/types.ts'; + import { server } from '../src/server.ts'; +-import { splitMessage } from '../src/message.ts'; +-import { submitSmParams } from '../src/send-sms.ts'; ++import { splitMessage } from '../src/messages/message.ts'; ++import { submitSmParams } from '../src/messages/send-sms.ts'; + + // smsbox HTTP hosts, one per variant - all point at the same node:2775 SMPP server. + const MAIN_SMSBOX = process.env.MAIN_SMSBOX ?? 'kannel-smsbox:13013'; +@@ -141,9 +142,16 @@ function variantFromSystemId(systemId: string): Variant | undefined { + return undefined; + } + ++function variantOf(session: Session): Variant | undefined { ++ return (session.userData as { variant?: Variant } | undefined)?.variant; ++} ++ + const allSms: { sms: Sms; variant: Variant }[] = []; + const allDlrs: { dlr: Dlr; variant: Variant }[] = []; + const bindPdus: { params: Record; variant: Variant }[] = []; ++const submitPdus: { text: string; variant: Variant }[] = []; ++/** How long a submit_sm carrying this text is held before it is answered. */ ++const holdMs = new Map(); + + const { err: serverErr, server: smpp } = await server({ + authenticate: ({ password, systemId }) => { +@@ -154,6 +162,20 @@ const { err: serverErr, server: smpp } = await server({ + return variant ? { userData: { variant } } : false; + }, + idleTimeout: 40_000, ++ onRequest: async (_session, pduObj) => { ++ if (!isCommand(pduObj, 'submit_sm')) return false; ++ ++ const hold = holdMs.get(paramText(pduObj.params.short_message)); ++ ++ if (hold !== undefined) await delay(hold); ++ ++ return false; ++ }, ++ onSms: sms => { ++ const variant = variantOf(sms.session); ++ ++ if (variant) allSms.push({ sms, variant }); ++ }, + port: SMPP_PORT, + }); + +@@ -164,6 +186,14 @@ const smppServer = smpp; + + smppServer.on('session', session => { + session.on('incomingPduObj', pduObj => { ++ if (isCommand(pduObj, 'submit_sm')) { ++ const variant = variantOf(session); ++ ++ if (variant) submitPdus.push({ text: paramText(pduObj.params.short_message), variant }); ++ ++ return; ++ } ++ + if (!pduObj.cmdName.startsWith('bind_')) return; + + const variant = variantFromSystemId(paramText(pduObj.params.system_id)); +@@ -171,14 +201,8 @@ smppServer.on('session', session => { + if (variant) bindPdus.push({ params: pduObj.params, variant }); + }); + +- session.on('sms', sms => { +- const variant = (session.userData as { variant?: Variant } | undefined)?.variant; +- +- if (variant) allSms.push({ sms, variant }); +- }); +- + session.on('dlr', dlr => { +- const variant = (session.userData as { variant?: Variant } | undefined)?.variant; ++ const variant = variantOf(session); + + if (variant) allDlrs.push({ dlr, variant }); + }); +@@ -190,7 +214,7 @@ after(async () => { + }); + + function sessionsFor(variant: Variant): Session[] { +- return [...smppServer.sessions].filter(s => (s.userData as { variant?: Variant } | undefined)?.variant === variant); ++ return [...smppServer.sessions].filter(s => variantOf(s) === variant); + } + + async function waitForSessions(variant: Variant, count: number, budget = 15_000): Promise { +@@ -308,7 +332,6 @@ describe('S1 - MT from Kannel with delivery reports', () => { + assert.equal(sms.to, '46709771337'); + assert.equal(sms.dlr, true); + +- assert.equal((await sms.sendResp()).err, undefined); + + // dlr-mask bit 8: Kannel fires this off the submit_sm_resp alone, before any receipt. + const submitAck = await waitForDlrCallback(sms.smsId, '8'); +@@ -339,7 +362,6 @@ describe('long MT from Kannel', () => { + const sms = await waitForSms('main', text, 15_000); + + assert.equal(sms.message, text); +- assert.equal((await sms.sendResp()).err, undefined); + }); + + test('UCS-2 text with 一 and an emoji reassembles whole', async () => { +@@ -351,7 +373,6 @@ describe('long MT from Kannel', () => { + const sms = await waitForSms('main', text, 15_000); + + assert.equal(sms.message, text); +- assert.equal((await sms.sendResp()).err, undefined); + }); + }); + +@@ -365,7 +386,6 @@ describe('S11 - GSM extension characters', () => { + const sms = await waitForSms('main', text); + + assert.equal(sms.message, text); +- assert.equal((await sms.sendResp()).err, undefined); + }); + }); + +@@ -461,24 +481,22 @@ describe('S6 - wait-ack expiry and keepalive', () => { + await waitForSessions('main', 1); + + const text = 's6-slow-resp'; +- const before = allSms.filter(e => e.variant === 'main').length; + ++ holdMs.set(text, 7000); + await sendsms(MAIN_SMSBOX, { from: '46701113311', text, to: '46709771337' }); + +- const sms = await waitForSms('main', text); ++ const arrived = await waitFor(() => submitPdus.find(e => e.variant === 'main' && e.text === text)); + +- await delay(7000); +- await sms.sendResp().catch(() => undefined); ++ assert.ok(arrived, 'the submit_sm never reached our server'); + + // wait-ack-expire defaults to 0x00 (disconnect/reconnect); reconnect-delay is 1s, so give it +- // room to rebind and possibly resend the same submit_sm on the new session. +- await delay(4000); ++ // room past the 7s hold to rebind and possibly resend the same submit_sm on the new session. ++ await delay(11_000); + +- const after = allSms.filter(e => e.variant === 'main' && e.sms.message === text); ++ const onRecord = allSms.filter(e => e.variant === 'main' && e.sms.message === text).length; + +- assert.ok(after.length >= 1, 'the original sms is still on record'); +- // Recorded for findings, not asserted: whether a resend duplicated the sms is peer behaviour. +- void before; ++ // Recorded for findings, not asserted: whether a resend put the sms on record is peer behaviour. ++ void onRecord; + }); + }); + +@@ -504,7 +522,6 @@ describe('iv33 variant - interface_version 0x33', () => { + + const sms = await waitForSms('iv33', text); + +- assert.equal((await sms.sendResp()).err, undefined); + await waitForDlrCallback(sms.smsId, '8'); + await sms.sendDlr('DELIVERED'); + await waitForDlrCallback(sms.smsId, '1'); +@@ -517,10 +534,6 @@ describe('maxp1 variant - max-pending-submits 1', () => { + + assert.ok(session); + +- // max-pending-submits=1 means bearerbox holds the link to one outstanding submit_sm at a +- // time - answer each as it lands, or the whole burst stalls behind the first message. +- session.on('sms', sms => { void sms.sendResp(); }); +- + const texts = Array.from({ length: 20 }, (_, i) => `burst-${String(i).padStart(2, '0')}`); + + // Sequential, not Promise.all: concurrent fetch()es reach smsbox's HTTP listener in whatever +@@ -565,7 +578,6 @@ describe('notrx variant - separate TX and RX binds', () => { + const sms = await waitForSms('notrx', text); + + assert.equal(sms.session, tx); +- assert.equal((await sms.sendResp()).err, undefined); + }); + + test('a receipt built on the receiver bind reaches Kannel; the transmitter bind cannot carry one', async () => { +@@ -598,7 +610,6 @@ describe('notrx variant - separate TX and RX binds', () => { + + const sms = await waitForSms('notrx', text); + +- assert.equal((await sms.sendResp()).err, undefined); + await waitForDlrCallback(sms.smsId, '8'); + + const receiptDate = '2609051200'; +diff --git a/interop-tests/php.test.ts b/interop-tests/php.test.ts +index 0415ce9..53a6e43 100644 +--- a/interop-tests/php.test.ts ++++ b/interop-tests/php.test.ts +@@ -1,7 +1,7 @@ + import assert from 'node:assert/strict'; + import test, { after, describe } from 'node:test'; + import type { Session } from '../src/session.ts'; +-import type { Sms } from '../src/sms.ts'; ++import type { Sms } from '../src/messages/sms.ts'; + import { paramText } from '../src/defs/types.ts'; + import { isCommand, server } from '../src/index.ts'; + +@@ -48,6 +48,11 @@ const { err: serverErr, server: smpp } = await server({ + + return true; + }, ++ onSms: sms => { ++ const systemId = systemIdBySession.get(sms.session); ++ ++ if (systemId) allSms.push({ sms, systemId }); ++ }, + port: SMPP_PORT, + }); + +@@ -62,18 +67,6 @@ smppServer.on('session', session => { + + systemIdBySession.set(session, paramText(pduObj.params.system_id)); + }); +- +- session.on('sms', sms => { +- const systemId = systemIdBySession.get(session); +- +- if (systemId) allSms.push({ sms, systemId }); +- +- // php-smpp's submit_sm() blocks synchronously reading the response on the same connection +- // that sent it, so answering here (rather than after this event's own test observes the +- // sms) is the only way that read ever completes - unlike python-smpplib's driver, this one +- // has no separate reader thread to poll afterwards. +- void sms.sendResp(); +- }); + }); + + after(async () => { +diff --git a/interop-tests/python.test.ts b/interop-tests/python.test.ts +index 84f1c66..81676b1 100644 +--- a/interop-tests/python.test.ts ++++ b/interop-tests/python.test.ts +@@ -1,7 +1,7 @@ + import assert from 'node:assert/strict'; + import test, { after, describe } from 'node:test'; + import type { Session } from '../src/session.ts'; +-import type { Sms } from '../src/sms.ts'; ++import type { Sms } from '../src/messages/sms.ts'; + import { encodings } from '../src/defs/encodings.ts'; + import { paramText } from '../src/defs/types.ts'; + import { isCommand, server } from '../src/index.ts'; +@@ -68,6 +68,11 @@ const { err: serverErr, server: smpp } = await server({ + + return true; + }, ++ onSms: sms => { ++ const systemId = systemIdBySession.get(sms.session); ++ ++ if (systemId) allSms.push({ sms, systemId }); ++ }, + port: SMPP_PORT, + }); + +@@ -82,12 +87,6 @@ smppServer.on('session', session => { + + systemIdBySession.set(session, paramText(pduObj.params.system_id)); + }); +- +- session.on('sms', sms => { +- const systemId = systemIdBySession.get(session); +- +- if (systemId) allSms.push({ sms, systemId }); +- }); + }); + + after(async () => { +@@ -134,7 +133,6 @@ async function waitForReceived(name: string, predicate: (e: ReceivedEntry) => bo + + type AckResult = { messageId?: string; status?: number }; + +-// A single-segment submit_sm is only answered once sendResp() is called on the arrived sms, so + // /submit itself does not wait for the ack (see driver.py) - this polls for it afterwards. + async function waitForAck(name: string, sequence: number, budget = 8000): Promise { + const found = await waitFor(async () => { +@@ -149,7 +147,7 @@ async function waitForAck(name: string, sequence: number, budget = 8000): Promis + } + + async function echoBack(session: Session, sms: Sms, dataCoding: number, text: string): Promise { +- const encName = dataCoding === 3 ? 'LATIN1' : dataCoding === 8 ? 'UCS2' : 'ASCII'; ++ const encName = dataCoding === 3 ? 'LATIN1' : dataCoding === 8 ? 'UCS2' : 'GSM7'; + const buf = encodings[encName].encode(text); + const sent = await session.send({ + cmdName: 'deliver_sm', +@@ -181,9 +179,7 @@ describe('S11 - encodings (python-smpplib)', () => { + + assert.equal(sent.ok, true, JSON.stringify(sent)); + +- const sms = await waitForSms('s11-basic', expectBasic + extensionChars); +- +- assert.equal((await sms.sendResp()).err, undefined); ++ await waitForSms('s11-basic', expectBasic + extensionChars); + assert.equal((await waitForAck('s11-basic', sent.sequence as number)).status, 0); + }); + +@@ -194,9 +190,7 @@ describe('S11 - encodings (python-smpplib)', () => { + + assert.equal(sent.ok, true, JSON.stringify(sent)); + +- const sms = await waitForSms('s11-ff', 'before-\f'); +- +- assert.equal((await sms.sendResp()).err, undefined); ++ await waitForSms('s11-ff', 'before-\f'); + assert.equal((await waitForAck('s11-ff', sent.sequence as number)).status, 0); + }); + +@@ -208,9 +202,7 @@ describe('S11 - encodings (python-smpplib)', () => { + + assert.equal(sent.ok, true, JSON.stringify(sent)); + +- const sms = await waitForSms('s11-latin1', text); +- +- assert.equal((await sms.sendResp()).err, undefined); ++ await waitForSms('s11-latin1', text); + assert.equal((await waitForAck('s11-latin1', sent.sequence as number)).status, 0); + }); + +@@ -222,9 +214,7 @@ describe('S11 - encodings (python-smpplib)', () => { + + assert.equal(sent.ok, true, JSON.stringify(sent)); + +- const sms = await waitForSms('s11-ucs2', text); +- +- assert.equal((await sms.sendResp()).err, undefined); ++ await waitForSms('s11-ucs2', text); + assert.equal((await waitForAck('s11-ucs2', sent.sequence as number)).status, 0); + }); + +@@ -238,7 +228,6 @@ describe('S11 - encodings (python-smpplib)', () => { + + const sms = await waitForSms('s11-echo-gsm', 'seed'); + +- assert.equal((await sms.sendResp()).err, undefined); + assert.equal((await waitForAck('s11-echo-gsm', sent.sequence as number)).status, 0); + + const session = sessionFor('s11-echo-gsm'); +@@ -261,7 +250,6 @@ describe('S11 - encodings (python-smpplib)', () => { + + const sms = await waitForSms('s11-echo-ucs2', seed); + +- assert.equal((await sms.sendResp()).err, undefined); + assert.equal((await waitForAck('s11-echo-ucs2', sent.sequence as number)).status, 0); + + const session = sessionFor('s11-echo-ucs2'); +@@ -288,7 +276,6 @@ describe('S11 - encodings (python-smpplib)', () => { + + const sms = await waitForSms('s11-quirk', '§'); + +- assert.equal((await sms.sendResp()).err, undefined); + assert.equal((await waitForAck('s11-quirk', sent.sequence as number)).status, 0); + + // Sent back the spec-correct way (byte 0x5F again), python's own (non-standard) table reads +@@ -320,8 +307,6 @@ describe('S2 - long messages (python-smpplib, UDH)', () => { + + const sms = await waitForSms(name, text, 15_000); + +- assert.equal(sms.answeredOnArrival, true); +- + const results = sent.results as { messageId: string; status: number }[]; + + assert.equal(results.length, segments); +@@ -382,8 +367,7 @@ describe('Refusals via onRequest', () => { + + await bindReader(name); + +- // Refused through onRequest, before reassembly and before the sms event, so this is answered +- // without any sendResp() call - unlike every other submit in this file. ++ // Refused through onRequest, before reassembly and before onSms. + const refused = await post('/submit', { dataCoding: 0, from: '46700000001', name, text: 'nope', to: REFUSED_DEST }); + + assert.equal(refused.ok, true, JSON.stringify(refused)); +@@ -397,9 +381,7 @@ describe('Refusals via onRequest', () => { + + assert.equal(after1.ok, true, JSON.stringify(after1)); + +- const sms = await waitForSms(name, 'still works'); +- +- assert.equal((await sms.sendResp()).err, undefined); ++ await waitForSms(name, 'still works'); + assert.equal((await waitForAck(name, after1.sequence as number)).status, 0); + }); + }); +diff --git a/interop-tests/smppsim.test.ts b/interop-tests/smppsim.test.ts +index 8a08817..0a37e3c 100644 +--- a/interop-tests/smppsim.test.ts ++++ b/interop-tests/smppsim.test.ts +@@ -1,11 +1,11 @@ + import assert from 'node:assert/strict'; + import test, { describe } from 'node:test'; +-import type { Dlr } from '../src/dlr.ts'; ++import type { Dlr } from '../src/messages/dlr.ts'; + import type { EncodingName } from '../src/defs/encodings.ts'; +-import type { MessageDlr } from '../src/dlr-merger.ts'; +-import type { PduObject } from '../src/pdu.ts'; ++import type { MessageDlr } from '../src/messages/dlr-merger.ts'; ++import type { PduObject } from '../src/wire/pdu.ts'; + import type { Session } from '../src/session.ts'; +-import type { Sms } from '../src/sms.ts'; ++import type { Sms } from '../src/messages/sms.ts'; + import { client } from '../src/client.ts'; + import { closeAfter } from '../test/teardown.ts'; + import { consts } from '../src/defs/constants.ts'; +@@ -72,14 +72,6 @@ function collectDlrs(session: Session): Received[] { + return received; + } + +-function collectSms(session: Session): Sms[] { +- const collected: Sms[] = []; +- +- session.on('sms', sms => { collected.push(sms); }); +- +- return collected; +-} +- + const DLR_RETRY_BUDGET_MS = 3000; + const DLR_MAX_ATTEMPTS = 10; + +@@ -148,7 +140,7 @@ async function sendUntilComplete( + sms: Sms[], + message: string, + encoding?: EncodingName, +-): Promise<{ reassembled: Sms; smsIds: string[] }> { ++): Promise { + for (let attempt = 0; attempt < DLR_MAX_ATTEMPTS; attempt++) { + const sent = await session.sendSms({ + dlr: true, +@@ -164,12 +156,12 @@ async function sendUntilComplete( + + const complete = await waitFor(() => { + const allIntact = ids.every(id => dlrLooksIntact(dlrs.find(r => r.dlr.smsId === id))); +- const reassembled = sms.find(s => s.message === message); ++ const reassembled = sms.some(s => s.message === message); + +- return allIntact && reassembled ? { reassembled } : undefined; ++ return allIntact && reassembled ? true : undefined; + }, DLR_RETRY_BUDGET_MS); + +- if (complete) return { reassembled: complete.reassembled, smsIds: ids }; ++ if (complete) return ids; + } + + throw new Error(`no attempt got both an intact DLR per segment and a loopback reassembly within ${String(DLR_MAX_ATTEMPTS)} tries`); +@@ -202,16 +194,15 @@ describe('smppsim - C3+C7 long MT, receipts and loopback reassembly', () => { + + for (const testCase of cases) { + test(testCase.label, async t => { +- const { err, session } = await bind(PEER_HOST); ++ const sms: Sms[] = []; ++ const { err, session } = await bind(PEER_HOST, { onSms: s => { sms.push(s); } }); + + assert.equal(err, undefined); + assert.ok(session); + closeAfter(t, session); + + const dlrs = collectDlrs(session); +- const sms = collectSms(session); +- +- const { reassembled, smsIds } = await sendUntilComplete( ++ const smsIds = await sendUntilComplete( + session, + dlrs, + sms, +@@ -235,8 +226,6 @@ describe('smppsim - C3+C7 long MT, receipts and loopback reassembly', () => { + assert.equal(received.dlr.receipt.id, id); + assert.equal(received.dlr.receipt.err, '000'); + } +- +- assert.equal((await reassembled.sendResp()).err, undefined); + }); + } + }); +@@ -588,14 +577,13 @@ describe('smppsim - C15 bind version negotiation', () => { + + describe('smppsim - C17 encodings round trip over loopback', () => { + test('Latin-1 (å ä ö)', async t => { +- const { err, session } = await bind(PEER_HOST); ++ const sms: Sms[] = []; ++ const { err, session } = await bind(PEER_HOST, { onSms: s => { sms.push(s); } }); + + assert.equal(err, undefined); + assert.ok(session); + closeAfter(t, session); + +- const sms = collectSms(session); +- + await session.sendSms({ encoding: 'LATIN1', from: FROM, message: 'å ä ö', to: TO }); + + const received = await waitFor(() => sms.find(s => s.message === 'å ä ö')); +@@ -605,14 +593,13 @@ describe('smppsim - C17 encodings round trip over loopback', () => { + }); + + test('UCS-2', async t => { +- const { err, session } = await bind(PEER_HOST); ++ const sms: Sms[] = []; ++ const { err, session } = await bind(PEER_HOST, { onSms: s => { sms.push(s); } }); + + assert.equal(err, undefined); + assert.ok(session); + closeAfter(t, session); + +- const sms = collectSms(session); +- + await session.sendSms({ encoding: 'UCS2', from: FROM, message: 'ucs2 round trip', to: TO }); + + const received = await waitFor(() => sms.find(s => s.message === 'ucs2 round trip')); +@@ -622,14 +609,13 @@ describe('smppsim - C17 encodings round trip over loopback', () => { + }); + + test('flash (data_coding records the message-class group)', async t => { +- const { err, session } = await bind(PEER_HOST); ++ const sms: Sms[] = []; ++ const { err, session } = await bind(PEER_HOST, { onSms: s => { sms.push(s); } }); + + assert.equal(err, undefined); + assert.ok(session); + closeAfter(t, session); + +- const sms = collectSms(session); +- + await session.sendSms({ flash: true, from: FROM, message: 'flash test', to: TO }); + + const received = await waitFor(() => sms.find(s => s.message === 'flash test')); +@@ -640,13 +626,12 @@ describe('smppsim - C17 encodings round trip over loopback', () => { + }); + + test('a raw submit_sm with data_coding 0xF0 is read as flash', async t => { +- const { err, session } = await bind(PEER_HOST); ++ const sms: Sms[] = []; ++ const { err, session } = await bind(PEER_HOST, { onSms: s => { sms.push(s); } }); + + assert.equal(err, undefined); + assert.ok(session); + closeAfter(t, session); +- +- const sms = collectSms(session); + const body = 'message class test'; + + const sent = await session.send({ +@@ -668,13 +653,12 @@ describe('smppsim - C17 encodings round trip over loopback', () => { + }); + + test('a raw submit_sm with 8-bit binary and a UDH (esm_class 0x40)', async t => { +- const { err, session } = await bind(PEER_HOST); ++ const sms: Sms[] = []; ++ const { err, session } = await bind(PEER_HOST, { onSms: s => { sms.push(s); } }); + + assert.equal(err, undefined); + assert.ok(session); + closeAfter(t, session); +- +- const sms = collectSms(session); + // A UDH carrying no recognised concatenation IE (0x00/0x08): one element in GSM 03.40's + // reserved-for-future-use range (0x70), so Wireshark's gsm_sms_ud dissector - which + // validates the *typed* IEs' own lengths (0x01 "Special SMS Message Indication" must be +diff --git a/interop-tests/smscsim.test.ts b/interop-tests/smscsim.test.ts +index ba23042..80cc3bb 100644 +--- a/interop-tests/smscsim.test.ts ++++ b/interop-tests/smscsim.test.ts +@@ -1,8 +1,8 @@ + import assert from 'node:assert/strict'; + import test, { describe } from 'node:test'; +-import type { Dlr } from '../src/dlr.ts'; ++import type { Dlr } from '../src/messages/dlr.ts'; + import type { Session } from '../src/session.ts'; +-import type { Sms } from '../src/sms.ts'; ++import type { Sms } from '../src/messages/sms.ts'; + import { client } from '../src/client.ts'; + import { closeAfter } from '../test/teardown.ts'; + +@@ -163,9 +163,11 @@ describe('smscsim - multipart segments', () => { + + describe('smscsim - MO injection through the web UI', () => { + test('a message posted to the web page arrives as an sms event', async t => { ++ const incoming: Sms[] = []; + const { err, session } = await client({ + bindType: 'transceiver', + host: PEER_HOST, ++ onSms: sms => { incoming.push(sms); }, + port: PEER_PORT, + username: 'mo-inject', + }); +@@ -174,10 +176,6 @@ describe('smscsim - MO injection through the web UI', () => { + assert.ok(session); + closeAfter(t, session); + +- const incoming: Sms[] = []; +- +- session.on('sms', sms => { incoming.push(sms); }); +- + const response = await fetch(`http://${PEER_HOST}:${String(PEER_WEB_PORT)}/`, { + body: new URLSearchParams({ + message: 'hello from the web UI', +@@ -198,8 +196,6 @@ describe('smscsim - MO injection through the web UI', () => { + assert.equal(sms.from, '46701113311'); + assert.equal(sms.to, '46709771337'); + assert.equal(sms.message, 'hello from the web UI'); +- +- assert.equal((await sms.sendResp()).err, undefined); + }); + }); + +diff --git a/src/client.ts b/src/client.ts +index 6b7299c..ff97053 100644 +--- a/src/client.ts ++++ b/src/client.ts +@@ -1,17 +1,19 @@ + 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 './session/bind-direction.ts'; ++import type { ReconnectOptions, SmsHandler } from './session/session-options.ts'; + import type { SmppLog } from './log.ts'; +-import type { SmsIdFormat } from './sms-id.ts'; ++import type { SmsIdFormat } from './messages/sms-id.ts'; + import type { Socket } from 'node:net'; + export type { BindType }; + +-import { ReconnectLoop } from './reconnect-loop.ts'; ++import { ReconnectLoop } from './session/reconnect-loop.ts'; + import { Session } from './session.ts'; +-import { checkSessionOptions } from './session-options.ts'; ++import { checkSessionOptions } from './session/session-options.ts'; + import { connect as netConnect } from 'node:net'; + import { connect as tlsConnect } from 'node:tls'; +-import { defaultInterfaceVersion } from './defs/constants.ts'; ++import { defaults } from './defaults.ts'; ++import { errorFrom } from './error-from.ts'; + import { guardedLog } from './log.ts'; + + /** `fromStart` puts the very first connect and bind through the same backoff loop as a drop. */ +@@ -29,6 +31,8 @@ export type ClientOptions = { + interfaceVersion?: number; + log?: SmppLog; + maxOutstanding?: number; ++ /** Every mobile-originated message. Without one, the SMSC's deliveries are refused. */ ++ onSms?: SmsHandler; + password?: string; + port?: number; + reconnect?: ReconnectTuning | false; +@@ -41,19 +45,6 @@ export type ClientOptions = { + username?: string; + }; + +-const defaults = { +- bindType: 'transceiver', +- connectTimeout: 10_000, +- enquireLinkInterval: 20_000, +- host: 'localhost', +- /** The idle timeout is what notices a dead link, so it has to outlast one silent probe. */ +- idleTimeoutFactor: 2, +- interfaceVersion: defaultInterfaceVersion, +- password: 'pass', +- port: 2775, +- username: 'user', +-} as const; +- + function armConnectTimeout( + sock: Socket, + connectTimeout: number | false, +@@ -97,7 +88,7 @@ function openSocket(options: ClientOptions): Promise> { + try { + sock = secure ? tlsConnect({ host, port, ...tlsOptions }) : netConnect({ host, port }); + } catch (thrown: unknown) { +- resolve({ err: thrown instanceof Error ? thrown : new Error(String(thrown)) }); ++ resolve({ err: errorFrom(thrown) }); + + return; + } +@@ -201,9 +192,11 @@ function createSession(options: ClientOptions, log: SmppLog, sock: Socket): Sess + + return new Session({ + enquireLinkInterval, +- idleTimeout: options.idleTimeout ?? enquireLinkInterval * defaults.idleTimeoutFactor, ++ // Two silent probes, so the default idle timeout tracks a retuned interval. ++ idleTimeout: options.idleTimeout ?? enquireLinkInterval * 2, + log, + maxOutstanding: options.maxOutstanding, ++ onSms: options.onSms, + reconnect: reconnectFor(options, log), + responseTimeout: options.responseTimeout, + shutdownTimeout: options.shutdownTimeout, +diff --git a/src/defaults.ts b/src/defaults.ts +new file mode 100644 +index 0000000..9b8a69f +--- /dev/null ++++ b/src/defaults.ts +@@ -0,0 +1,33 @@ ++/** Every option's default, in milliseconds where it is a time. README's option tables read from here. */ ++export const defaults = { ++ bindType: 'transceiver', ++ connectTimeout: 10_000, ++ enquireLinkInterval: 20_000, ++ host: 'localhost', ++ /** Two probes have to go unanswered before a link counts as dead. */ ++ idleTimeout: 40_000, ++ interfaceVersion: 0x34, ++ maxDelay: 30_000, ++ maxOctets: 64 * 1024 * 1024, ++ maxOutstanding: 10, ++ maxReassembly: 1000, ++ minDelay: 1000, ++ password: 'pass', ++ port: 2775, ++ reassemblyTimeout: 300_000, ++ responseTimeout: 30_000, ++ shutdownTimeout: 5000, ++ systemId: '', ++ username: 'user', ++} as const; ++ ++/** The bounds that are not options: what a session holds for an application or a peer that never answers. */ ++export const bounds = { ++ /** Receipts of a multipart message can be a working day apart, so the cap does the bounding. */ ++ dlrMergeTimeout: 86_400_000, ++ /** An `onSms` handler still running after this is no longer counted or waited for. */ ++ handlerTimeout: 300_000, ++ maxDlrMerges: 1000, ++ /** Handlers running at once; a message arriving past it is refused so the peer retries. */ ++ maxRunningHandlers: 1000, ++} as const; +diff --git a/src/defs/constants.ts b/src/defs/constants.ts +index 0ed155e..d7ea22e 100644 +--- a/src/defs/constants.ts ++++ b/src/defs/constants.ts +@@ -1,6 +1,3 @@ +-/** The version declared on the wire. The tables below cover 5.0, which is a superset of it. */ +-export const defaultInterfaceVersion = 0x34; +- + /** Spec rule, not a preference: a peer declaring less than 3.4 is sent no optional parameters. */ + export const optionalParamsMinVersion = 0x34; + +diff --git a/src/defs/encodings.ts b/src/defs/encodings.ts +index c454b02..054e3eb 100644 +--- a/src/defs/encodings.ts ++++ b/src/defs/encodings.ts +@@ -1,4 +1,4 @@ +-export type EncodingName = 'ASCII' | 'LATIN1' | 'UCS2'; ++export type EncodingName = 'GSM7' | 'LATIN1' | 'UCS2'; + + export type Encoding = { + decode: (buffer: Uint8Array) => string; +@@ -103,7 +103,7 @@ const ucs2: Encoding = { + }; + + export const encodings: Record = { +- ASCII: ascii, ++ GSM7: ascii, + LATIN1: latin1, + UCS2: ucs2, + }; +@@ -115,7 +115,7 @@ export function isEncodingName(value: unknown): value is EncodingName { + } + + export function detect(value: string): EncodingName { +- if (encodings.ASCII.match(value)) return 'ASCII'; ++ if (encodings.GSM7.match(value)) return 'GSM7'; + if (encodings.LATIN1.match(value)) return 'LATIN1'; + + return 'UCS2'; +@@ -163,14 +163,14 @@ function messageClassEncoding(dataCoding: number): EncodingName | undefined { + if (messageClassOf(dataCoding) === undefined) return undefined; + + if ((dataCoding & 0xF0) === 0xF0) { +- return (dataCoding & 0x04) === 0x04 ? 'LATIN1' : 'ASCII'; ++ return (dataCoding & 0x04) === 0x04 ? 'LATIN1' : 'GSM7'; + } + + const alphabet = (dataCoding >> 2) & 0x03; + + if (alphabet === 0x01) return 'LATIN1'; + +- return alphabet === 0x02 ? 'UCS2' : 'ASCII'; ++ return alphabet === 0x02 ? 'UCS2' : 'GSM7'; + } + + /** +@@ -186,7 +186,7 @@ export function encodingByDataCoding(dataCoding: number): EncodingName { + if (dataCoding === 0x08) return 'UCS2'; + + // 0x02 and 0x04 are 8-bit binary, 0x03 is Latin-1. +- return dataCoding >= 0x02 && dataCoding <= 0x04 ? 'LATIN1' : 'ASCII'; ++ return dataCoding >= 0x02 && dataCoding <= 0x04 ? 'LATIN1' : 'GSM7'; + } + + /** +@@ -194,7 +194,7 @@ export function encodingByDataCoding(dataCoding: number): EncodingName { + * takes 0x00, the SMSC default alphabet, rather than SMPP 3.4 5.2.19's 0x01, which is IA5. + */ + export const dataCodingByEncoding: Readonly> = { +- ASCII: 0x00, ++ GSM7: 0x00, + LATIN1: 0x03, + UCS2: 0x08, + }; +diff --git a/src/held-messages.ts b/src/held-messages.ts +deleted file mode 100644 +index b9e740e..0000000 +--- a/src/held-messages.ts ++++ /dev/null +@@ -1,201 +0,0 @@ +-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'; +-import type { SmsHandlers } from './sms.ts'; +-import type { SmppLog } from './log.ts'; +-import { ExpiringGroups } from './expiring-groups.ts'; +-import { IdleWaiters } from './idle-waiters.ts'; +-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; +- sendPastDrain: SmsHandlers['send']; +- session: Session; +- timeout: number; +-}; +- +-/** The peer's own sequence number, which is what our answer to this message will carry. */ +-function keyOf(pduObjs: PduObject[]): string | undefined { +- const first = pduObjs[0]; +- +- return first ? String(first.seqNr) : undefined; +-} +- +-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. +- */ +-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> { +- 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; +- 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(); +- private readonly route: HoldRoute; +- +- constructor(options: HeldMessagesOptions) { +- this.held = new ExpiringGroups({ +- max: options.max, +- now: options.now, +- 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 }; +- } +- +- get octetsHeld(): number { +- return this.held.weight; +- } +- +- get size(): number { +- return this.held.size; +- } +- +- /** Whether a message arriving now is past the bound, once the expired are swept. */ +- 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); +- +- this.sweep(); +- +- if (this.held.get(key)) { +- this.log.warn('heldMessages - replacing a message on a re-used sequence number', { seqNr: Number(key) }); +- } +- +- this.held.set(key, pduObjs); +- this.held.weigh(key, pduObjs.reduce((sum, pduObj) => sum + retainedOctets(pduObj), 0)); +- +- return hold; +- } +- +- offer(pduObjs: PduObject[], answeredAs?: string): MessageHold | 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); +- +- this.offered.set(sms, hold); +- +- if (!this.route.session.emit('sms', sms)) hold.release(); +- +- return hold; +- } +- +- /** 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(); +- } +- +- holds(pduObjs: PduObject[]): boolean { +- const key = keyOf(pduObjs); +- +- return key !== undefined && this.held.get(key) === pduObjs; +- } +- +- release(pduObjs: PduObject[]): void { +- const key = keyOf(pduObjs); +- +- // Identity, not the key: a wrapped sequence number must not release someone else's message. +- if (key === undefined || this.held.get(key) !== pduObjs) return; +- +- this.held.delete(key); +- this.settle(); +- } +- +- /** Drops every message: their segments went with the link, so no answer of ours correlates now. */ +- clear(): void { +- this.held.takeAll(); +- this.idleWaiters.settle(); +- } +- +- /** Resolves 0 once every message has been answered, or with how many have not. */ +- idle(timeout: number, signal: AbortSignal | undefined): Promise { +- 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(); +- +- if (expired.length === 0) return; +- +- this.log.warn('heldMessages - messages the application never answered', { +- messages: expired.length, +- }); +- this.settle(); +- } +- +- private settle(): void { +- if (this.held.size === 0) this.idleWaiters.settle(); +- } +-} +diff --git a/src/index.ts b/src/index.ts +index 71fa973..d9cd7d9 100644 +--- a/src/index.ts ++++ b/src/index.ts +@@ -16,9 +16,9 @@ export { + objToPdu, + pduReturn, + pduToObj, +-} from './pdu.ts'; ++} from './wire/pdu.ts'; + +-export { maxPduLength, PduRefusedError } from './pdu-refusal.ts'; ++export { maxPduLength, PduRefusedError } from './wire/pdu-refusal.ts'; + + export { + bitCount, +@@ -27,23 +27,24 @@ export { + smppDate, + smppTime, + splitMessage, +-} from './message.ts'; ++} from './messages/message.ts'; + +-export { dlrFromPdu, parseReceipt, receiptCodes } from './dlr.ts'; +-export { messageOctets } from './message-body.ts'; +-export { concatOf } from './concat.ts'; +-export { concatInfo } from './udh.ts'; +-export { PduFramer } from './pdu-framer.ts'; +-export { uuidv7 } from './uuid.ts'; ++export { dlrFromPdu, parseReceipt, receiptCodes } from './messages/dlr.ts'; ++export { messageOctets } from './messages/message-body.ts'; ++export { concatOf } from './messages/concat.ts'; ++export { concatInfo } from './messages/udh.ts'; ++export { PduFramer } from './wire/pdu-framer.ts'; ++export { uuidv7 } from './messages/uuid.ts'; + +-export type { BindType, ClientOptions } from './client.ts'; +-export type { Dlr, Receipt } from './dlr.ts'; +-export type { SendDlrResult, SendRespOptions, Sms } from './sms.ts'; +-export type { Concat } from './concat.ts'; +-export type { ConcatInfo } from './udh.ts'; ++export type { BindType } from './session/bind-direction.ts'; ++export type { ClientOptions } from './client.ts'; ++export type { Dlr, Receipt } from './messages/dlr.ts'; ++export type { SendDlrResult, Sms } from './messages/sms.ts'; ++export type { Concat } from './messages/concat.ts'; ++export type { ConcatInfo } from './messages/udh.ts'; + export type { Result, VoidResult } from './result.ts'; + export type { SmppLog } from './log.ts'; +-export type { SmsIdFormat, SmsIdNotation } from './sms-id.ts'; ++export type { SmsIdFormat, SmsIdNotation } from './messages/sms-id.ts'; + export type { + AuthenticateInput, + AuthenticateResult, +@@ -59,14 +60,15 @@ export type { + SendSmsResult, + SessionEvents, + SessionOptions, ++ SmsHandler, + } from './session.ts'; + export type { CommandName, PduParams, PduParamsInput } from './defs/commands.ts'; + export type { ConstGroup, MessageState, SubmitMessagingMode } from './defs/constants.ts'; + export type { Encoding, EncodingName, Unencodable } from './defs/encodings.ts'; + export type { ErrorName } from './defs/errors.ts'; +-export type { PduObject, PduObjectInput, TlvInputs } from './pdu.ts'; +-export type { PduHeader } from './pdu-refusal.ts'; +-export type { SplitOptions } from './message.ts'; ++export type { PduObject, PduObjectInput, TlvInputs } from './wire/pdu.ts'; ++export type { PduHeader } from './wire/pdu-refusal.ts'; ++export type { SplitOptions } from './messages/message.ts'; + export type { Tlv, TlvDefinition, TlvName, Tlvs } from './defs/tlvs.ts'; + export type { DestAddress, ParamValue, TlvValue, UnsuccessSme, WireType } from './defs/types.ts'; + +diff --git a/src/concat.ts b/src/messages/concat.ts +similarity index 90% +rename from src/concat.ts +rename to src/messages/concat.ts +index b56f421..4b2f74b 100644 +--- a/src/concat.ts ++++ b/src/messages/concat.ts +@@ -1,9 +1,9 @@ + import type { ConcatInfo } from './udh.ts'; +-import type { PduObject } from './pdu.ts'; ++import type { PduObject } from '../wire/pdu.ts'; + import { concatInfo } from './udh.ts'; +-import { hasUdh } from './defs/constants.ts'; ++import { hasUdh } from '../defs/constants.ts'; + import { messageOctets } from './message-body.ts'; +-import { paramNumber } from './defs/types.ts'; ++import { paramNumber } from '../defs/types.ts'; + + /** Where a segment sits in its message, and what ties it to the rest of that message. */ + export type Concat = ConcatInfo & { +diff --git a/src/dlr-merger.ts b/src/messages/dlr-merger.ts +similarity index 93% +rename from src/dlr-merger.ts +rename to src/messages/dlr-merger.ts +index 0c20cae..0ef448f 100644 +--- a/src/dlr-merger.ts ++++ b/src/messages/dlr-merger.ts +@@ -1,6 +1,6 @@ + import type { Dlr } from './dlr.ts'; +-import type { MessageState } from './defs/constants.ts'; +-import type { SmppLog } from './log.ts'; ++import type { MessageState } from '../defs/constants.ts'; ++import type { SmppLog } from '../log.ts'; + import { ExpiringGroups } from './expiring-groups.ts'; + import { parseSegmentId } from './sms-id.ts'; + +@@ -126,7 +126,7 @@ export class DlrMerger { + + if (group.parts.size < group.expected.size) return undefined; + +- this.close(base); ++ this.spend(base); + + const segments = [...group.parts.entries()].sort(([a], [b]) => a - b).map(([, one]) => one); + const worst = segments.reduce((carry, one) => (severity[one.statusMsg] > severity[carry.statusMsg] ? one : carry)); +@@ -142,7 +142,7 @@ export class DlrMerger { + /** Drops every group past its deadline. Runs before each collect and on its own timer. */ + sweep(): void { + for (const [base, group] of this.groups.takeExpired()) { +- this.close(base); ++ this.spend(base); + this.log.info('dlrMerger - incomplete receipts expired', { base, expected: group.expected.size }); + } + } +@@ -151,7 +151,7 @@ export class DlrMerger { + this.spent.takeExpired(); + + if (this.groups.get(base) !== undefined || this.spent.get(base) === true) { +- this.close(base); ++ this.spend(base); + this.log.info('dlrMerger - message id handed out again, leaving its receipts unmerged', { base }); + + return; +@@ -162,7 +162,8 @@ export class DlrMerger { + this.groups.set(base, { expected, parts: new Map() }); + } + +- private close(base: string): void { ++ /** The base is finished with: its group goes, and a later message handed the same ids merges nothing. */ ++ private spend(base: string): void { + this.groups.delete(base); + this.spent.delete(base); + +@@ -178,7 +179,7 @@ export class DlrMerger { + + const [base] = oldest; + +- this.close(base); ++ this.spend(base); + this.log.warn('dlrMerger - buffer full, dropping the oldest message', { base, max: this.max }); + } + } +diff --git a/src/dlr.ts b/src/messages/dlr.ts +similarity index 95% +rename from src/dlr.ts +rename to src/messages/dlr.ts +index 27ab4dd..6fcb33b 100644 +--- a/src/dlr.ts ++++ b/src/messages/dlr.ts +@@ -1,12 +1,12 @@ +-import type { MessageState } from './defs/constants.ts'; +-import type { TlvValue } from './defs/types.ts'; +-import type { PduObject } from './pdu.ts'; ++import type { MessageState } from '../defs/constants.ts'; ++import type { TlvValue } from '../defs/types.ts'; ++import type { PduObject } from '../wire/pdu.ts'; + import type { SmsIdFormat } from './sms-id.ts'; +-import { consts, constsById, hasUdh, messageTypeOf } from './defs/constants.ts'; +-import { encodings } from './defs/encodings.ts'; ++import { consts, constsById, hasUdh, messageTypeOf } from '../defs/constants.ts'; ++import { encodings } from '../defs/encodings.ts'; + import { messageOctets } from './message-body.ts'; + import { normaliseSmsId } from './sms-id.ts'; +-import { paramNumber, paramText } from './defs/types.ts'; ++import { paramNumber, paramText } from '../defs/types.ts'; + import { udhLength } from './udh.ts'; + + /** +diff --git a/src/expiring-groups.ts b/src/messages/expiring-groups.ts +similarity index 100% +rename from src/expiring-groups.ts +rename to src/messages/expiring-groups.ts +diff --git a/src/message-body.ts b/src/messages/message-body.ts +similarity index 92% +rename from src/message-body.ts +rename to src/messages/message-body.ts +index a244b8f..cb07d66 100644 +--- a/src/message-body.ts ++++ b/src/messages/message-body.ts +@@ -1,4 +1,4 @@ +-import type { PduObject } from './pdu.ts'; ++import type { PduObject } from '../wire/pdu.ts'; + + /** + * The user data, wherever the peer put it. SMPP 3.4 5.3.2.32 carries up to 64 KB in +diff --git a/src/message.ts b/src/messages/message.ts +similarity index 95% +rename from src/message.ts +rename to src/messages/message.ts +index 0665019..b8fb548 100644 +--- a/src/message.ts ++++ b/src/messages/message.ts +@@ -1,7 +1,7 @@ +-import type { Result } from './result.ts'; +-import type { EncodingName } from './defs/encodings.ts'; +-import { dataCodingByEncoding, detect, encodingByDataCoding, encodings, unencodable, unencodableText } from './defs/encodings.ts'; +-import { hasUdh } from './defs/constants.ts'; ++import type { Result } from '../result.ts'; ++import type { EncodingName } from '../defs/encodings.ts'; ++import { dataCodingByEncoding, detect, encodingByDataCoding, encodings, unencodable, unencodableText } from '../defs/encodings.ts'; ++import { hasUdh } from '../defs/constants.ts'; + import { udhLength } from './udh.ts'; + + /** A single SMS carries 1120 bits, whatever the alphabet. */ +@@ -11,7 +11,7 @@ const singleMessageBits = 1120; + export const maxSegments = 255; + + /** Budget per segment: the 134 octets left of 140 after the UDH, or the 153 septets GSM packs into them. */ +-const segmentUnits: Record = { ASCII: 153, LATIN1: 134, UCS2: 134 }; ++const segmentUnits: Record = { GSM7: 153, LATIN1: 134, UCS2: 134 }; + + export type SplitOptions = { + encoding?: EncodingName; +@@ -70,7 +70,7 @@ export function bitCount(message: string, encoding?: EncodingName): number { + const encoded = encodings[resolved].encode(message); + + // GSM characters are packed seven bits to a septet; everything else stays octet-aligned. +- return resolved === 'ASCII' ? encoded.length * 7 : encoded.length * 8; ++ return resolved === 'GSM7' ? encoded.length * 7 : encoded.length * 8; + } + + /** +diff --git a/src/reassembly.ts b/src/messages/reassembly.ts +similarity index 96% +rename from src/reassembly.ts +rename to src/messages/reassembly.ts +index 4f3d2c5..ede9553 100644 +--- a/src/reassembly.ts ++++ b/src/messages/reassembly.ts +@@ -1,11 +1,12 @@ + import type { Concat } from './concat.ts'; +-import type { PduObject } from './pdu.ts'; +-import type { SmppLog } from './log.ts'; ++import type { PduObject } from '../wire/pdu.ts'; ++import type { SmppLog } from '../log.ts'; + import { ExpiringGroups } from './expiring-groups.ts'; ++import { defaults } from '../defaults.ts'; + import { decodeMessage } from './message.ts'; + import { detach, retainedOctets } from './retained-pdu.ts'; + import { messageOctets } from './message-body.ts'; +-import { paramNumber, paramText } from './defs/types.ts'; ++import { paramNumber, paramText } from '../defs/types.ts'; + import { uuidv7 } from './uuid.ts'; + + /** A concatenated message given up on, whose segments the peer has already been answered for. */ +@@ -42,8 +43,6 @@ export type Collected = + whole?: PduObject[] | undefined; + }; + +-export const defaultMaxOctets = 64 * 1024 * 1024; +- + type Group = { + parts: Map; + smsId: string; +@@ -89,7 +88,7 @@ export class Reassembler { + private readonly onLost: (lost: LostGroup) => void; + + constructor(options: ReassemblerOptions) { +- this.maxOctets = options.maxOctets ?? defaultMaxOctets; ++ this.maxOctets = options.maxOctets ?? defaults.maxOctets; + this.groups = new ExpiringGroups({ + max: options.max, + maxWeight: this.maxOctets, +diff --git a/src/retained-pdu.ts b/src/messages/retained-pdu.ts +similarity index 91% +rename from src/retained-pdu.ts +rename to src/messages/retained-pdu.ts +index c46e2c9..16f0447 100644 +--- a/src/retained-pdu.ts ++++ b/src/messages/retained-pdu.ts +@@ -1,6 +1,6 @@ +-import type { ParamValue } from './defs/types.ts'; +-import type { PduObject } from './pdu.ts'; +-import { tlvOctets } from './defs/types.ts'; ++import type { ParamValue } from '../defs/types.ts'; ++import type { PduObject } from '../wire/pdu.ts'; ++import { tlvOctets } from '../defs/types.ts'; + + /** Wire reads hand back views, so retaining one PDU would pin the whole chunk it arrived in. */ + export function detach(pduObj: PduObject): PduObject { +diff --git a/src/send-sms.ts b/src/messages/send-sms.ts +similarity index 95% +rename from src/send-sms.ts +rename to src/messages/send-sms.ts +index 2e70932..af4ce6b 100644 +--- a/src/send-sms.ts ++++ b/src/messages/send-sms.ts +@@ -1,15 +1,15 @@ +-import type { EncodingName, Unencodable } from './defs/encodings.ts'; +-import type { ParamValue } from './defs/types.ts'; +-import type { SubmitMessagingMode } from './defs/constants.ts'; +-import type { PduObject, PduObjectInput } from './pdu.ts'; +-import type { Result } from './result.ts'; +-import type { SmppLog } from './log.ts'; ++import type { EncodingName, Unencodable } from '../defs/encodings.ts'; ++import type { ParamValue } from '../defs/types.ts'; ++import type { SubmitMessagingMode } from '../defs/constants.ts'; ++import type { PduObject, PduObjectInput } from '../wire/pdu.ts'; ++import type { Result } from '../result.ts'; ++import type { SmppLog } from '../log.ts'; + import type { SmsIdNotation } from './sms-id.ts'; + import { UnansweredError } from './unanswered-error.ts'; +-import { consts, defaultMessagingMode, isMessagingMode, isSubmitMessagingMode, submitMessagingModes } from './defs/constants.ts'; +-import { cstring, paramText } from './defs/types.ts'; +-import { dataCodingByEncoding, detect, encodingNames, isEncodingName, unencodable, unencodableText } from './defs/encodings.ts'; +-import { namedValue } from './error-from.ts'; ++import { consts, defaultMessagingMode, isMessagingMode, isSubmitMessagingMode, submitMessagingModes } from '../defs/constants.ts'; ++import { cstring, paramText } from '../defs/types.ts'; ++import { dataCodingByEncoding, detect, encodingNames, isEncodingName, unencodable, unencodableText } from '../defs/encodings.ts'; ++import { namedValue } from '../error-from.ts'; + import { normaliseSmsId } from './sms-id.ts'; + import { maxSegments, smppTime, splitMessage } from './message.ts'; + +diff --git a/src/sms-id.ts b/src/messages/sms-id.ts +similarity index 95% +rename from src/sms-id.ts +rename to src/messages/sms-id.ts +index 8a558b6..f0f3d87 100644 +--- a/src/sms-id.ts ++++ b/src/messages/sms-id.ts +@@ -1,5 +1,5 @@ +-import type { CommandName } from './defs/commands.ts'; +-import type { ParamValue } from './defs/types.ts'; ++import type { CommandName } from '../defs/commands.ts'; ++import type { ParamValue } from '../defs/types.ts'; + + const notations = { + decimal: { digits: /^[0-9]+$/, prefix: '' }, +diff --git a/src/messages/sms.ts b/src/messages/sms.ts +new file mode 100644 +index 0000000..37c4018 +--- /dev/null ++++ b/src/messages/sms.ts +@@ -0,0 +1,152 @@ ++import type { MessageState } from '../defs/constants.ts'; ++import type { PduObject, PduObjectInput, TlvInputs } from '../wire/pdu.ts'; ++import type { Result } from '../result.ts'; ++import type { Session } from '../session.ts'; ++import { UnansweredError } from './unanswered-error.ts'; ++import { consts } from '../defs/constants.ts'; ++import { decodeSegments } from './reassembly.ts'; ++import { messageClassOf } from '../defs/encodings.ts'; ++import { paramText } from '../defs/types.ts'; ++import { receiptCodes, transientStates } from './dlr.ts'; ++import { smppDate } from './message.ts'; ++import { segmentId } from './sms-id.ts'; ++ ++/** `pduObjs` holds what the peer took, so a partial failure names what is already receipted. */ ++export type SendDlrResult = { ++ err?: Error; ++ pduObjs: PduObject[]; ++ /** Segments that went out unanswered. The peer may have taken them, so sending again may duplicate. */ ++ unanswered: number; ++}; ++ ++/** A received SMS, already answered `ESME_ROK` under `smsId`. A multipart message carries every segment's PDU. */ ++export type Sms = { ++ dlr: boolean; ++ /** GSM 03.38 message class 0: shown on arrival and not stored. */ ++ flash: boolean; ++ from: string; ++ message: string; ++ pduObjs: PduObject[]; ++ /** Sends a delivery report back to the sender. Defaults to DELIVERED. */ ++ sendDlr: (status?: MessageState) => Promise; ++ session: Session; ++ /** The id the peer was answered with: the base of `-` where it arrived in segments. */ ++ smsId: string; ++ submitTime: Date; ++ to: string; ++}; ++ ++export type SmsInput = { ++ pduObjs: PduObject[]; ++ session: Session; ++ smsId: string; ++}; ++ ++/** A receipt reports on a message this session took, so a shutdown's drain lets it out. */ ++export type SendReceipt = (input: PduObjectInput) => Promise>; ++ ++/** GSM 03.38 section 4 gives class 0 immediate display; every other class is stored somewhere. */ ++const immediateDisplayClass = 0; ++ ++export function createSms(input: SmsInput, sendReceipt: SendReceipt): Sms { ++ const first = input.pduObjs[0]; ++ const registered = first?.params.registered_delivery; ++ const dataCoding = first?.params.data_coding; ++ const sms: Sms = { ++ dlr: typeof registered === 'number' && registered !== 0, ++ flash: typeof dataCoding === 'number' && messageClassOf(dataCoding) === immediateDisplayClass, ++ from: paramText(first?.params.source_addr), ++ message: decodeSegments(input.pduObjs), ++ pduObjs: input.pduObjs, ++ sendDlr: status => sendDlr(sms, sendReceipt, status), ++ session: input.session, ++ smsId: input.smsId, ++ submitTime: new Date(), ++ to: paramText(first?.params.destination_addr), ++ }; ++ ++ return sms; ++} ++ ++/** The receipt as text, which is all of it a peer below SMPP 3.4 is allowed to be sent. */ ++function receiptText(sms: Sms, smsId: string, status: MessageState): string { ++ const delivered = status === 'DELIVERED'; ++ const failed = !delivered && !transientStates.includes(status); ++ ++ return [ ++ `id:${smsId}`, ++ 'sub:001', ++ `dlvrd:${delivered ? '001' : '000'}`, ++ `submit date:${smppDate(sms.submitTime)}`, ++ `done date:${smppDate(new Date())}`, ++ `stat:${receiptCodes[status]}`, ++ `err:${failed ? '001' : '000'}`, ++ 'text:', ++ ].join(' '); ++} ++ ++function receiptTlvs(smsId: string, status: MessageState): TlvInputs { ++ return { ++ message_state: { tagValue: consts.MESSAGE_STATE[status] }, ++ receipted_message_id: { tagValue: smsId }, ++ }; ++} ++ ++function collectReceipt(sent: Result<{ pduObj: PduObject }>[]): SendDlrResult { ++ const pduObjs: PduObject[] = []; ++ let failure: Error | undefined; ++ let unanswered = 0; ++ ++ for (const one of sent) { ++ if (one.err) { ++ if (one.err instanceof UnansweredError) unanswered++; ++ ++ failure ??= one.err; ++ } else if (one.pduObj.cmdStatus === 'ESME_ROK') { ++ pduObjs.push(one.pduObj); ++ } else { ++ const refusal = one.pduObj.cmdStatus ?? String(one.pduObj.cmdStatusId); ++ ++ failure ??= new Error(`deliver_sm refused by the peer: ${refusal}`); ++ } ++ } ++ ++ return failure ? { err: failure, pduObjs, unanswered } : { pduObjs, unanswered }; ++} ++ ++async function sendDlr( ++ sms: Sms, ++ sendReceipt: SendReceipt, ++ status: MessageState = 'DELIVERED', ++): Promise { ++ const { session } = sms; ++ ++ if (!session.bindAllows('deliver_sm')) { ++ return { ++ err: new Error('A transmitter-bound session does not carry deliver_sm'), ++ pduObjs: [], ++ unanswered: 0, ++ }; ++ } ++ ++ const total = sms.pduObjs.length; ++ // Together, not one after a response: the receipt is one message's, and goes out as one. ++ const sent = await Promise.all(sms.pduObjs.map((_segment, index) => { ++ const smsId = segmentId(sms.smsId, index, total); ++ ++ return sendReceipt({ ++ cmdName: 'deliver_sm', ++ params: { ++ destination_addr: sms.from, ++ esm_class: transientStates.includes(status) ++ ? consts.ESM_CLASS.INTERMEDIATE_DELIVERY ++ : consts.ESM_CLASS.MC_DELIVERY_RECEIPT, ++ short_message: receiptText(sms, smsId, status), ++ source_addr: sms.to, ++ }, ++ ...(session.acceptsOptionalParams() ? { tlvs: receiptTlvs(smsId, status) } : {}), ++ }); ++ })); ++ ++ return collectReceipt(sent); ++} +diff --git a/src/udh.ts b/src/messages/udh.ts +similarity index 100% +rename from src/udh.ts +rename to src/messages/udh.ts +diff --git a/src/unanswered-error.ts b/src/messages/unanswered-error.ts +similarity index 100% +rename from src/unanswered-error.ts +rename to src/messages/unanswered-error.ts +diff --git a/src/uuid.ts b/src/messages/uuid.ts +similarity index 100% +rename from src/uuid.ts +rename to src/messages/uuid.ts +diff --git a/src/outgoing-requests.ts b/src/outgoing-requests.ts +deleted file mode 100644 +index a0adf24..0000000 +--- a/src/outgoing-requests.ts ++++ /dev/null +@@ -1,178 +0,0 @@ +-import type { LinkLife } from './link-life.ts'; +-import type { PduObject, PduObjectInput } from './pdu.ts'; +-import type { PduTransport } from './pdu-transport.ts'; +-import type { Result, VoidResult } from './result.ts'; +-import type { SendOptions } from './session-options.ts'; +-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 { objToPdu } from './pdu.ts'; +- +-export type OutgoingRequestsOptions = { +- link: LinkLife; +- log: SmppLog; +- maxOutstanding: number; +- responseTimeout: number; +- transport: PduTransport; +-}; +- +-/** `retryOnNextLink`: the write failed, so nothing reached the socket and another link may carry it. */ +-type Attempt = { result: Result<{ pduObj: PduObject }>; retryOnNextLink: boolean }; +- +-function abortedBeforeSend(): Error { +- return new Error('Aborted before the request was sent'); +-} +- +-/** A response carries the request's sequence number, which only sendReturn() has. */ +-function misuse(input: PduObjectInput): Error | undefined { +- return input.cmdName.endsWith('_resp') +- ? new Error(`Use sendReturn() for responses, not send(): ${input.cmdName}`) +- : undefined; +-} +- +-/** Everything this end asks of the peer: which link carries it, how many at once, and the answer. */ +-export class OutgoingRequests { +- private readonly link: LinkLife; +- private readonly log: SmppLog; +- private readonly pending: PendingRequests; +- private readonly responseTimeout: number; +- private readonly transport: PduTransport; +- private readonly window: SendWindow; +- +- 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; +- this.window = new SendWindow({ limit: options.maxOutstanding, log: options.log }); +- } +- +- canCarry(): boolean { +- return this.link.isUp() && !this.transport.sock.destroyed; +- } +- +- /** The link is gone, and every answer still owed on it with it. */ +- linkLost(): void { +- this.pending.settleAll(new Error('Session closed before a response arrived')); +- } +- +- /** Hands a response to the request waiting for it. False means nothing was. */ +- deliver(pduObj: PduObject): boolean { +- return this.pending.deliver(pduObj); +- } +- +- /** A response the codec refused settles its request instead of leaving it to time out. */ +- settleRefused(seqNr: number, err: Error): void { +- this.pending.settle(seqNr, { err }); +- } +- +- request(input: PduObjectInput, options: SendOptions): Promise> { +- // Ahead of the drain, so a misuse is named as one rather than blamed on the shutdown. +- const wrong = misuse(input); +- +- if (wrong) return Promise.resolve({ err: wrong }); +- +- // With no link, the request is refused as closed further on. +- if (this.link.isStopped() && 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> { +- const refused = this.refuse(input, options); +- +- if (refused) return { 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); +- } +- +- 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; +- } +- } +- +- /** Straight onto the current link, for what has to go out either way. */ +- async requestOnCurrentLink( +- input: PduObjectInput, +- options: SendOptions = {}, +- ): Promise> { +- 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 { +- const unfinished = await this.window.idle(timeout, signal); +- +- if (unfinished === 0) return {}; +- +- this.log.warn('outgoingRequests - shutting down with requests unfinished', { timeout, unfinished }); +- +- return { err: new Error(`Shut down with ${String(unfinished)} request(s) unfinished`) }; +- } +- +- /** 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(); +- } +- +- /** Why a request cannot go out at all, as opposed to not yet. */ +- private refuse(input: PduObjectInput, options: SendOptions): Error | undefined { +- // Before the link and the window, or an aborted call waits for what it will never use. +- return misuse(input) ?? (options.signal?.aborted === true ? abortedBeforeSend() : undefined); +- } +- +- private async attempt(input: PduObjectInput, options: SendOptions): Promise { +- // pending.wait() alone settles the caller while the request still goes out to the peer. +- if (options.signal?.aborted === true) { +- return { result: { err: abortedBeforeSend() }, retryOnNextLink: false }; +- } +- +- const seqNr = this.pending.nextSeqNr(); +- const built = objToPdu({ ...input, seqNr }); +- +- if (built.err) return { result: { err: built.err }, retryOnNextLink: false }; +- +- const response = this.pending.wait(seqNr, { +- signal: options.signal, +- timeout: this.responseTimeout, +- }); +- const written = this.transport.write(built.buffer); +- +- if (written.err) { +- this.pending.settle(seqNr, { err: written.err }); +- +- return { result: { err: written.err }, retryOnNextLink: true }; +- } +- +- const answered = await response; +- +- // It went out, so a failure now means the peer may have taken it and the answer was the loss. +- return { result: answered.err ? { err: new UnansweredError(answered.err) } : answered, retryOnNextLink: false }; +- } +-} +diff --git a/src/server.ts b/src/server.ts +index 2b9aefe..742b1c9 100644 +--- a/src/server.ts ++++ b/src/server.ts +@@ -1,15 +1,17 @@ +-import type { BindType, CloseOptions, OnRequest } from './session-options.ts'; +-import type { PduObject, TlvInputs } from './pdu.ts'; ++import type { BindType } from './session/bind-direction.ts'; ++import type { CloseOptions, OnRequest, SmsHandler } from './session/session-options.ts'; ++import type { PduObject, TlvInputs } from './wire/pdu.ts'; + import type { Result, VoidResult } from './result.ts'; + import type { Server as NetServer, Socket } from 'node:net'; + 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 { Session } from './session.ts'; ++import { bindTypeFromCommand } from './session/bind-direction.ts'; ++import { checkSessionOptions } from './session/session-options.ts'; + import { createServer as createNetServer } from 'node:net'; + import { createServer as createTlsServer } from 'node:tls'; +-import { defaultInterfaceVersion } from './defs/constants.ts'; ++import { defaults } from './defaults.ts'; + import { errorFrom } from './error-from.ts'; + import { paramText } from './defs/types.ts'; + import { guardedLog } from './log.ts'; +@@ -35,6 +37,8 @@ export type ServerOptions = { + maxReassembly?: number; + /** First refusal on every request a bound peer sends. */ + onRequest?: OnRequest; ++ /** Every message a bound peer submits, on any session. Without one, submissions are refused. */ ++ onSms?: SmsHandler; + port?: number; + reassemblyTimeout?: number; + responseTimeout?: number; +@@ -49,13 +53,6 @@ export type ServerEvents = { + session: [Session]; + }; + +-const defaults = { +- idleTimeout: 40_000, +- interfaceVersion: defaultInterfaceVersion, +- port: 2775, +- systemId: defaultSystemId, +-}; +- + /** A listener may return a promise: an `async` one that rejects is routed like one that throws. */ + type ServerListener = (...args: ServerEvents[K]) => unknown; + +@@ -239,6 +236,7 @@ function onConnection(sock: Socket, options: ServerOptions, server: SmppServer): + maxOctets: options.maxOctets, + maxReassembly: options.maxReassembly, + onRequest: (bound, pduObj) => handleRequest(bound, pduObj, options), ++ onSms: options.onSms, + reassemblyTimeout: options.reassemblyTimeout, + responseTimeout: options.responseTimeout, + shutdownTimeout: options.shutdownTimeout, +@@ -269,28 +267,11 @@ function createSecureListener(tlsOptions: TlsOptions, log: SmppLog): TlsServer { + return listener; + } + +-/** A caller without types would otherwise reach a TypeError once per PDU rather than once here. */ +-function checkHooks(options: ServerOptions): VoidResult { +- for (const name of ['authenticate', 'onRequest'] as const) { +- const hook: unknown = options[name]; +- +- if (hook !== undefined && typeof hook !== 'function') { +- return { err: new Error(`${name} must be a function, got ${typeof hook}`) }; +- } +- } +- +- return {}; +-} +- + function checkOptions(options: ServerOptions, log: SmppLog, port: number): VoidResult { + const checked = checkSessionOptions(options); + + if (checked.err) return { err: checked.err }; + +- const hooks = checkHooks(options); +- +- if (hooks.err) return hooks; +- + if (options.tls === true) { + log.warn('server - tls without a certificate', { port }); + +diff --git a/src/session.ts b/src/session.ts +index 1fd9b46..f6e7805 100644 +--- a/src/session.ts ++++ b/src/session.ts +@@ -1,30 +1,33 @@ ++import type { BindType, LinkEnd, SessionBind } from './session/bind-direction.ts'; + import type { ErrorName } from './defs/errors.ts'; +-import type { MessageDlr } from './dlr-merger.ts'; ++import type { Lane } from './session/outgoing-requests.ts'; ++import type { MessageDlr } from './messages/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 { PduObject, PduObjectInput, TlvInputs } from './wire/pdu.ts'; ++import type { PduRefusedError } from './wire/pdu-refusal.ts'; ++import type { CloseOptions, ReconnectOptions, SendOptions, SessionEvents, SessionOptions, SmsHandler } from './session/session-options.ts'; + import type { Result, VoidResult } from './result.ts'; +-import type { SendSmsOptions, SendSmsResult } from './send-sms.ts'; ++import type { SendSmsOptions, SendSmsResult } from './messages/send-sms.ts'; + import type { SmppLog } from './log.ts'; + import type { Socket } from 'node:net'; +-import { DlrMerger } from './dlr-merger.ts'; ++import { DlrMerger } from './messages/dlr-merger.ts'; + import { EventEmitter } from 'node:events'; +-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 { IncomingRequests } from './session/incoming-requests.ts'; ++import { LinkLife } from './session/link-life.ts'; ++import { LinkTimers } from './session/link-timers.ts'; ++import { OutgoingRequests } from './session/outgoing-requests.ts'; ++import { PduTransport } from './session/pdu-transport.ts'; ++import { ReconnectLoop } from './session/reconnect-loop.ts'; ++import { bindCarries, checkedBind, linkCommands } from './session/bind-direction.ts'; ++import { bounds, defaults } from './defaults.ts'; ++import { leftOf } from './session/idle-waiters.ts'; + import { errorFrom } from './error-from.ts'; + import { optionalParamsMinVersion } from './defs/constants.ts'; +-import { bindCarries, bindCommands, checkedBind, defaultSystemId, defaults } from './session-options.ts'; +-import { isResp, objToPdu, pduReturn } from './pdu.ts'; +-import { refusalAnswer } from './pdu-refusal.ts'; ++import { isResp, objToPdu, pduReturn } from './wire/pdu.ts'; ++import { refusalAnswer } from './wire/pdu-refusal.ts'; + import { guardedLog } from './log.ts'; +-import { submitSms, unsent } from './send-sms.ts'; +-import { ConcatReference } from './udh.ts'; ++import { submitSms, unsent } from './messages/send-sms.ts'; ++import { ConcatReference } from './messages/udh.ts'; + + export type { + CloseOptions, +@@ -35,13 +38,23 @@ export type { + SendSmsResult, + SessionEvents, + SessionOptions, ++ SmsHandler, + }; + export type { BindType }; +-export { bindCommands, defaultSystemId }; + + /** A listener may return a promise: an `async` one that rejects is routed like one that throws. */ + type SessionListener = (...args: SessionEvents[K]) => unknown; + ++/** What a drain could not wait out, in the words `close()` reports. */ ++function drainError(handlers: number, requests: number): VoidResult { ++ const lost = [ ++ ...(handlers > 0 ? [`${String(handlers)} handler(s) still running`] : []), ++ ...(requests > 0 ? [`${String(requests)} request(s) unfinished`] : []), ++ ]; ++ ++ return lost.length === 0 ? {} : { err: new Error(`Shut down with ${lost.join('; ')}`) }; ++} ++ + export class Session extends EventEmitter { + declare addListener: (event: K, listener: SessionListener) => this; + declare off: (event: K, listener: SessionListener) => this; +@@ -93,13 +106,11 @@ export class Session extends EventEmitter { + reason: unknown, + ...args: [event: keyof SessionEvents, ...rest: unknown[]] + ): void { +- const [event, ...rest] = args; ++ const [event] = args; + const error = errorFrom(reason); + + this.log.error('session - a listener rejected', { event, message: error.message }); + +- if (event === 'sms') this.incoming.listenerRejected(rest[0]); +- + if (event !== 'sessionError') this.emit('sessionError', error); + } + +@@ -108,7 +119,7 @@ export class Session extends EventEmitter { + + this.log = guardedLog(options.log); + this.options = options; +- this.dlrMerger = new DlrMerger({ log: this.log, max: defaults.maxDlrMerges, timeout: defaults.dlrMergeTimeout }); ++ this.dlrMerger = new DlrMerger({ log: this.log, max: bounds.maxDlrMerges, timeout: bounds.dlrMergeTimeout }); + this.reconnectLoop = this.loopFor(options.reconnect); + + const responseTimeout = options.responseTimeout ?? defaults.responseTimeout; +@@ -119,8 +130,7 @@ export class Session extends EventEmitter { + 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.linkLost(); }, + }); + this.transport = this.transportFor(options.sock); + this.outgoing = new OutgoingRequests({ +@@ -132,19 +142,19 @@ export class Session extends EventEmitter { + }); + this.incoming = new IncomingRequests({ + dlrMerger: this.dlrMerger, +- link: this.link, + log: this.log, + maxOctets: options.maxOctets, + maxReassembly: options.maxReassembly, + onRequest: options.onRequest, ++ onSms: options.onSms, + reassemblyTimeout: options.reassemblyTimeout, +- sendPastDrain: input => this.outgoing.requestPastDrain(input, {}), ++ sendReceipt: input => this.request(input, {}, 'receipt'), + session: this, + smsIdFormat: options.smsIdFormat, + systemId: options.systemId, + }); + +- this.resetTimers(); ++ this.timers.reset(); + } + + /** Replaced on reconnect, so hold the session rather than this. */ +@@ -162,13 +172,16 @@ export class Session extends EventEmitter { + return this.bind?.peerVersion; + } + +- /** Records a bind this link accepted or had accepted, until the next one. */ ++ /** Records a bind this link accepted or had accepted, which is what lets it carry requests. */ + bound(bindType: string, declaredVersion: unknown): VoidResult { + const checked = checkedBind(bindType, declaredVersion); + +- if (!checked.err) this.bind = checked.bind; ++ if (checked.err) return { err: checked.err }; ++ ++ this.bind = checked.bind; ++ this.link.open(); + +- return checked.err ? { err: checked.err } : {}; ++ return {}; + } + + /** Whether this session's bind direction carries a command. Consulted by the library's senders. */ +@@ -183,7 +196,11 @@ export class Session extends EventEmitter { + + /** Sends a request and resolves with the peer's response. */ + send(input: PduObjectInput, options: SendOptions = {}): Promise> { +- return this.outgoing.request(input, options); ++ return this.request(input, options, linkCommands.includes(input.cmdName) ? 'link' : 'message'); ++ } ++ ++ private request(input: PduObjectInput, options: SendOptions, lane: Lane): Promise> { ++ return this.outgoing.request(input, options, lane); + } + + /** Answers a request the peer sent us. Responses are never waited on. */ +@@ -237,7 +254,7 @@ export class Session extends EventEmitter { + const drained = await this.drain(undefined); + const wasOpen = this.link.isAttached(); + const sent = wasOpen +- ? await this.outgoing.requestOnCurrentLink({ cmdName: 'unbind' }) ++ ? await this.request({ cmdName: 'unbind' }, {}, 'link') + : { err: new Error('Session is closed') }; + const closedOnUnbind = wasOpen && !this.link.isAttached(); + +@@ -246,10 +263,7 @@ export class Session extends EventEmitter { + return sent.err && !closedOnUnbind ? { err: sent.err } : drained; + } + +- /** +- * Closes for good: refuses new sends, waits up to `shutdownTimeout` for the requests already sent +- * and the messages not yet answered, then tears down whatever is left. A session closed this way never reconnects. +- */ ++ /** Closes for good: refuses new sends, drains, then tears down whatever is left. Never reconnects. */ + async close(options: CloseOptions = {}): Promise { + const drained = await this.drain(options.signal); + +@@ -261,7 +275,7 @@ export class Session extends EventEmitter { + private transportFor(sock: Socket): PduTransport { + return new PduTransport({ + log: this.log, +- onClose: () => { this.onClose(); }, ++ onClose: () => { this.linkLost(); }, + onData: chunk => { this.onData(chunk); }, + onError: err => { this.emit('sessionError', err); }, + onFramed: pdu => { this.emit('incomingPdu', pdu); }, +@@ -269,7 +283,7 @@ export class Session extends EventEmitter { + onRefused: refused => { this.refuse(refused); }, + onUnreadable: err => { + this.emit('sessionError', err); +- this.teardown(); ++ this.linkLost(); + }, + }, sock); + } +@@ -290,37 +304,35 @@ export class Session extends EventEmitter { + sock: Socket, + bind: (session: Session) => Promise, + ): Promise { +- this.attach(sock); ++ this.transport.attach(sock); ++ this.link.attach(); + + const bound = await bind(this); + + if (bound.err) { +- this.teardown(); ++ this.linkLost(); + + return { err: bound.err }; + } + + // close() can land while the rebind is in flight. +- if (!this.link.retrying()) { +- this.teardown(); ++ if (!this.link.retrying() || !this.link.isUp()) { ++ this.linkLost(); + + return { err: new Error('Session closed while it was coming back up') }; + } + +- this.resetTimers(); +- this.link.open(); ++ this.timers.reset(); + 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. */ ++ /** ++ * Stops new sends, then waits up to `shutdownTimeout` for the handlers still running and the ++ * requests already on the wire. A receipt a handler sends is a request like any other by then. ++ */ + private async drain(signal: AbortSignal | undefined): Promise { + this.stop(); + +@@ -329,37 +341,19 @@ export class Session extends EventEmitter { + + 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); ++ const handlers = await this.incoming.idle(timeout, signal); ++ const requests = await this.outgoing.idle(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; +- +- return responseTimeout > 0 ? responseTimeout : defaults.responseTimeout; +- } ++ if (handlers > 0 || requests > 0) { ++ this.log.warn('session - shutting down with work unfinished', { handlers, requests, timeout }); ++ } + +- /** The session is over now, drained or not. Nothing brings it back. */ +- private end(): void { +- this.stop(); +- this.teardown(); +- this.dlrMerger.clear(); +- this.emitClose(); ++ return drainError(handlers, requests); + } + + /** No new sends, and no link after this one. */ +@@ -368,31 +362,51 @@ export class Session extends EventEmitter { + this.reconnectLoop?.stop(); + } + +- private emitClose(): void { +- if (!this.link.end()) return; ++ /** Drops the attached socket and everything that only that socket could carry. False when none was attached. */ ++ private dropSocket(): boolean { ++ if (!this.link.drop()) return false; + + this.outgoing.linkLost(); +- this.emit('close'); ++ this.timers.clear(); ++ this.incoming.linkLost(); ++ this.sock.destroy(); ++ ++ return true; + } + +- private teardown(): void { +- const lost = this.link.drop(); ++ /** The socket died, went quiet, or carried a stream that cannot be read. The next link follows, or the end. */ ++ private linkLost(): void { ++ // Read first: a `disconnected` listener may close() the session, and the drop still reports as one. ++ const retrying = this.link.retrying(); + +- if (!lost) return; ++ if (!this.dropSocket()) return; + +- this.outgoing.linkLost(); +- this.timers.clear(); +- this.incoming.clear(); +- this.sock.destroy(); ++ if (!retrying) { ++ this.end(); + +- // `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(); ++ return; ++ } ++ ++ this.emit('disconnected'); ++ this.reconnectLoop?.schedule(); ++ } ++ ++ /** The session is over, drained or not. Nothing brings it back. */ ++ private end(): void { ++ this.stop(); ++ this.dropSocket(); ++ ++ if (!this.link.end()) return; ++ ++ this.dlrMerger.clear(); ++ this.incoming.release(); ++ this.emit('close'); + } + + private onData(chunk: Buffer): void { + this.emit('data', chunk); +- this.resetTimers(); ++ ++ if (this.link.isAttached()) this.timers.reset(); + } + + private dispatch(pduObj: PduObject): void { +@@ -405,7 +419,7 @@ export class Session extends EventEmitter { + } + + this.emit('incomingPduObj', pduObj); +- // Every application hook and listener reached from an incoming PDU funnels through here. ++ // Every application hook reached from an incoming PDU funnels through here. + void this.incoming.handle(pduObj).catch((thrown: unknown) => { + const err = errorFrom(thrown); + +@@ -433,21 +447,4 @@ export class Session extends EventEmitter { + + 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/src/session/bind-direction.ts b/src/session/bind-direction.ts +new file mode 100644 +index 0000000..b5c3e94 +--- /dev/null ++++ b/src/session/bind-direction.ts +@@ -0,0 +1,76 @@ ++import type { Result } from '../result.ts'; ++import { namedValue } from '../error-from.ts'; ++ ++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'; ++ ++const bindTypes: readonly BindType[] = ['receiver', 'transceiver', 'transmitter']; ++ ++export const bindCommands: readonly string[] = bindTypes.map(bindType => `bind_${bindType}`); ++ ++/** The commands that manage the link itself, which go out on it as it is: unbound, or draining. */ ++export const linkCommands: readonly string[] = [...bindCommands, 'enquire_link', 'unbind']; ++ ++function isBindType(value: unknown): value is BindType { ++ return typeof value === 'string' && bindTypes.some(bindType => bindType === value); ++} ++ ++export function bindTypeFromCommand(cmdName: string): BindType | undefined { ++ const bindType = cmdName.startsWith('bind_') ? cmdName.slice('bind_'.length) : undefined; ++ ++ return isBindType(bindType) ? bindType : 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 quoted(value: unknown): string { ++ return typeof value === 'string' ? JSON.stringify(value) : namedValue(value); ++} ++ ++/** 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/idle-waiters.ts b/src/session/idle-waiters.ts +similarity index 100% +rename from src/idle-waiters.ts +rename to src/session/idle-waiters.ts +diff --git a/src/incoming-requests.ts b/src/session/incoming-requests.ts +similarity index 59% +rename from src/incoming-requests.ts +rename to src/session/incoming-requests.ts +index 51aeec7..1b0804b 100644 +--- a/src/incoming-requests.ts ++++ b/src/session/incoming-requests.ts +@@ -1,23 +1,24 @@ +-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 { 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 { concatOf } from './concat.ts'; +-import { detach } from './retained-pdu.ts'; +-import { dlrFromPdu } from './dlr.ts'; +-import { respIdParams, segmentId } from './sms-id.ts'; +-import { respNameFor } from './defs/commands.ts'; ++import type { Concat } from '../messages/concat.ts'; ++import type { DlrMerger } from '../messages/dlr-merger.ts'; ++import type { ErrorName } from '../defs/errors.ts'; ++import type { LostGroup, Refusal } from '../messages/reassembly.ts'; ++import type { OnRequest, SmsHandler } from './session-options.ts'; ++import type { PduObject } from '../wire/pdu.ts'; ++import type { SendReceipt } from '../messages/sms.ts'; ++import type { Session } from '../session.ts'; ++import type { SmppLog } from '../log.ts'; ++import type { SmsIdFormat } from '../messages/sms-id.ts'; ++import { Reassembler } from '../messages/reassembly.ts'; ++import { RunningHandlers } from './running-handlers.ts'; ++import { bindCommands, standsInFor } from './bind-direction.ts'; ++import { bounds, defaults } from '../defaults.ts'; ++import { concatOf } from '../messages/concat.ts'; ++import { createSms } from '../messages/sms.ts'; ++import { detach } from '../messages/retained-pdu.ts'; ++import { dlrFromPdu } from '../messages/dlr.ts'; ++import { respIdParams, segmentId } from '../messages/sms-id.ts'; ++import { respNameFor } from '../defs/commands.ts'; ++import { uuidv7 } from '../messages/uuid.ts'; + + /** Asks the peer to keep the message and retry. */ + function throttledStatus(carriedAs: string): ErrorName { +@@ -45,45 +46,42 @@ const lostReasons: Record = { + + export type IncomingRequestsOptions = { + dlrMerger: DlrMerger; +- link: LinkLife; + log: SmppLog; + maxOctets?: number | undefined; + maxReassembly?: number | undefined; + onRequest?: OnRequest | undefined; ++ onSms?: SmsHandler | undefined; + reassemblyTimeout?: number | undefined; +- sendPastDrain: HeldMessagesOptions['sendPastDrain']; ++ sendReceipt: SendReceipt; + session: Session; + smsIdFormat?: SmsIdFormat | undefined; + systemId?: string | undefined; + }; + +-/** Everything the peer asks of a session: messages, receipts, links and the answers to them. */ ++/** Every request the peer sends: the answer each one gets, and the message or report it becomes. */ + export class IncomingRequests { + private readonly dlrMerger: DlrMerger; +- private readonly held: HeldMessages; +- private readonly link: LinkLife; ++ private readonly handlers: RunningHandlers; + private readonly log: SmppLog; + private readonly onRequest: OnRequest | undefined; ++ private readonly onSms: SmsHandler | undefined; + private readonly reassembler: Reassembler; ++ private readonly sendReceipt: SendReceipt; + 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, ++ this.handlers = new RunningHandlers({ + log: options.log, +- max: defaults.maxHeldMessages, +- maxOctets: defaults.maxHeldOctets, +- sendPastDrain: options.sendPastDrain, +- session: options.session, +- timeout: defaults.heldMessageTimeout, ++ max: bounds.maxRunningHandlers, ++ onFailure: err => { options.session.emit('sessionError', err); }, ++ timeout: bounds.handlerTimeout, + }); +- this.link = options.link; + this.log = options.log; + this.onRequest = options.onRequest; ++ this.onSms = options.onSms; + this.reassembler = new Reassembler({ + log: options.log, + max: options.maxReassembly ?? defaults.maxReassembly, +@@ -91,20 +89,26 @@ export class IncomingRequests { + onLost: lost => { this.reportLost(lost); }, + timeout: options.reassemblyTimeout ?? defaults.reassemblyTimeout, + }); ++ this.sendReceipt = options.sendReceipt; + this.session = options.session; + this.smsIdFormat = options.smsIdFormat ?? {}; + this.systemId = options.systemId ?? defaults.systemId; + } + ++ /** How many `onSms` handlers are still running. */ ++ get running(): number { ++ return this.handlers.size; ++ } ++ + async handle(pduObj: PduObject): Promise { +- const generation = this.link.generation(); ++ const arrivedOn = this.session.sock; + const { onRequest } = this; + + // Called unbound, so the application's hook never sees this class as its `this`. + if (onRequest && await onRequest(this.session, pduObj)) return; + +- // The link it arrived on went while the hook ran, so nothing we answer now correlates. +- if (this.link.generation() !== generation) { ++ // An answer carries the request's sequence number, which only the socket it arrived on knows. ++ if (arrivedOn.destroyed || this.session.sock !== arrivedOn) { + this.log.info('session - dropping a request whose link went', { cmdName: pduObj.cmdName }); + + return; +@@ -148,26 +152,19 @@ export class IncomingRequests { + } + } + +- /** Drops the segments of every message that never became whole, and of every one still held. */ +- clear(): void { +- this.refusing = false; +- this.held.clear(); ++ /** The link went: its half-arrived groups go with it, since their references were that link's. */ ++ linkLost(): void { + this.reassembler.clear(); + } + +- listenerRejected(sms: unknown): void { +- this.held.listenerRejected(sms); ++ /** Waits out the handlers still running, and says how many never finished. */ ++ idle(timeout: number, signal: AbortSignal | undefined): Promise { ++ return this.handlers.idle(timeout, signal); + } + +- /** Waits out the messages the application still holds, and says how many it never answered. */ +- async drain(timeout: number, signal: AbortSignal | undefined): Promise { +- 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`) }; ++ /** The session is over, so no drain waits on a handler any more. */ ++ release(): void { ++ this.handlers.release(); + } + + private async unhandled(pduObj: PduObject): Promise { +@@ -211,49 +208,44 @@ export class IncomingRequests { + await this.session.sendReturn(pduObj); + } + +- private async refusedAtBound(pduObj: PduObject): Promise { +- 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))); ++ /** Why a message cannot be taken right now, or undefined where it can. */ ++ private refusal(pduObj: PduObject): ErrorName | undefined { ++ if (!this.onSms) { ++ this.log.info('session - refusing a message, since no onSms handler takes them', { cmdName: pduObj.cmdName }); + +- return true; ++ return 'ESME_RX_P_APPN'; + } + +- // 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 }); +- } ++ if (!this.handlers.full()) return undefined; ++ ++ this.log.verbose('session - handlers at their bound, asking the peer to retry', { ++ cmdName: pduObj.cmdName, ++ seqNr: pduObj.seqNr, ++ }); + +- return false; ++ return throttledStatus(this.carriedAs(pduObj)); + } + + /** +- * A concatenated message is answered segment by segment as it arrives: a peer that dispatches +- * one request at a time never sends the second segment until the first has been answered. ++ * Every message is answered as its segments arrive, before the handler sees it: a peer that ++ * dispatches one request at a time never sends the second segment until the first is answered. + */ + private async onMessage(pduObj: PduObject): Promise { +- if (await this.refusedAtBound(pduObj)) return; ++ const refused = this.refusal(pduObj); ++ ++ if (refused) { ++ await this.session.sendReturn(pduObj, refused); ++ ++ return; ++ } + + const concat = concatOf(pduObj); + + if (!concat) { +- this.held.offer([detach(pduObj)]); ++ const smsId = uuidv7(); ++ ++ await this.session.sendReturn(pduObj, 'ESME_ROK', respIdParams(pduObj.cmdName, smsId)); ++ this.deliver([detach(pduObj)], smsId); + + return; + } +@@ -275,7 +267,13 @@ export class IncomingRequests { + respIdParams(pduObj.cmdName, segmentId(collected.smsId, concat.part - 1, concat.total)), + ); + +- if (collected.whole) this.held.offer(collected.whole, collected.smsId); ++ if (collected.whole) this.deliver(collected.whole, collected.smsId); ++ } ++ ++ private deliver(pduObjs: PduObject[], smsId: string): void { ++ if (!this.onSms) return; ++ ++ this.handlers.run(createSms({ pduObjs, session: this.session, smsId }, this.sendReceipt), this.onSms); + } + + private reportLost(lost: LostGroup): void { +diff --git a/src/link-life.ts b/src/session/link-life.ts +similarity index 84% +rename from src/link-life.ts +rename to src/session/link-life.ts +index f44f2ea..7b2e7c2 100644 +--- a/src/link-life.ts ++++ b/src/session/link-life.ts +@@ -1,5 +1,5 @@ +-import type { SmppLog } from './log.ts'; +-import type { VoidResult } from './result.ts'; ++import type { SmppLog } from '../log.ts'; ++import type { VoidResult } from '../result.ts'; + + export type LinkLifeOptions = { + log: SmppLog; +@@ -10,7 +10,10 @@ export type LinkLifeOptions = { + timeout: number; + }; + +-/** `binding`: a socket is attached and its bind is not answered yet, so it carries nothing but that bind. */ ++/** ++ * `binding`: a socket is attached and no bind is recorded on it yet, so it carries link commands ++ * alone. `up`: bound, and carrying everything. `down`: no socket, one to come unless stopped. ++ */ + type Phase = 'binding' | 'down' | 'ended' | 'up'; + + type Waiter = (result: VoidResult) => void; +@@ -27,15 +30,14 @@ 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. */ ++/** Whether the session's link carries requests, and where one with no link to go out on waits for the next. */ + export class LinkLife { + private readonly log: SmppLog; + private readonly now: () => number; + private readonly reconnects: boolean; + private readonly timeout: number; + private readonly waiting = new Set(); +- private drops = 0; +- private phase: Phase = 'up'; ++ private phase: Phase = 'binding'; + private stopped = false; + + constructor(options: LinkLifeOptions) { +@@ -74,18 +76,13 @@ export class LinkLife { + return !this.isUp() && !this.isOver() && this.retrying(); + } + +- /** Changes with every drop, so what was read off one link can tell that link is gone. */ +- generation(): number { +- return this.drops; +- } +- + /** 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(); + } + + /** One budget for a request, however many links it waits through. */ +- hold(signal: AbortSignal | undefined): () => Promise { ++ budget(signal: AbortSignal | undefined): () => Promise { + const deadline = this.timeout > 0 ? this.now() + this.timeout : 0; + + return () => this.wait(deadline, signal); +@@ -100,6 +97,8 @@ export class LinkLife { + + /** The link is bound: everything held goes out on it. */ + open(): void { ++ if (!this.isAttached()) return; ++ + this.phase = 'up'; + + if (this.waiting.size > 0) { +@@ -109,14 +108,13 @@ export class LinkLife { + this.release({}); + } + +- /** 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; ++ /** The attached link is gone. False when there was none to lose. */ ++ drop(): boolean { ++ if (!this.isAttached()) return false; + + this.phase = 'down'; +- this.drops++; + +- return this.retrying() ? 'disconnected' : 'close'; ++ return true; + } + + stop(): void { +diff --git a/src/link-timers.ts b/src/session/link-timers.ts +similarity index 97% +rename from src/link-timers.ts +rename to src/session/link-timers.ts +index cd6710a..cbe1d36 100644 +--- a/src/link-timers.ts ++++ b/src/session/link-timers.ts +@@ -1,4 +1,4 @@ +-import type { SmppLog } from './log.ts'; ++import type { SmppLog } from '../log.ts'; + + export type LinkTimersOptions = { + /** How long between enquire_link probes. Undefined or 0 never probes. */ +diff --git a/src/session/outgoing-requests.ts b/src/session/outgoing-requests.ts +new file mode 100644 +index 0000000..8c83587 +--- /dev/null ++++ b/src/session/outgoing-requests.ts +@@ -0,0 +1,151 @@ ++import type { LinkLife } from './link-life.ts'; ++import type { PduObject, PduObjectInput } from '../wire/pdu.ts'; ++import type { PduTransport } from './pdu-transport.ts'; ++import type { Result } from '../result.ts'; ++import type { SendOptions } from './session-options.ts'; ++import type { SmppLog } from '../log.ts'; ++import { PendingRequests } from './pending-requests.ts'; ++import { SendWindow } from './send-window.ts'; ++import { UnansweredError } from '../messages/unanswered-error.ts'; ++import { objToPdu } from '../wire/pdu.ts'; ++ ++export type OutgoingRequestsOptions = { ++ link: LinkLife; ++ log: SmppLog; ++ maxOutstanding: number; ++ responseTimeout: number; ++ transport: PduTransport; ++}; ++ ++/** ++ * Which checks a request takes on its way out. `message`: refused once a shutdown began, waits for ++ * a bound link and a window slot, and is retried on the next link if it never reached the socket. ++ * `receipt`: the same, but let past a shutdown, since it reports on a message this session took. ++ * `link`: a bind, an unbind or a keepalive, which goes out now on the socket as it is. ++ */ ++export type Lane = 'link' | 'message' | 'receipt'; ++ ++/** `retry`: the write failed, so nothing reached the socket and another link may carry it. */ ++type Attempt = { result: Result<{ pduObj: PduObject }>; retry: boolean }; ++ ++function abortedBeforeSend(): Error { ++ return new Error('Aborted before the request was sent'); ++} ++ ++/** A response carries the request's sequence number, which only sendReturn() has. */ ++function misuse(input: PduObjectInput): Error | undefined { ++ return input.cmdName.endsWith('_resp') ++ ? new Error(`Use sendReturn() for responses, not send(): ${input.cmdName}`) ++ : undefined; ++} ++ ++/** Everything this end asks of the peer: which link carries it, how many at once, and the answer. */ ++export class OutgoingRequests { ++ private readonly link: LinkLife; ++ private readonly pending: PendingRequests; ++ private readonly responseTimeout: number; ++ private readonly transport: PduTransport; ++ private readonly window: SendWindow; ++ ++ constructor(options: OutgoingRequestsOptions) { ++ this.link = options.link; ++ this.pending = new PendingRequests(options.log); ++ this.responseTimeout = options.responseTimeout; ++ this.transport = options.transport; ++ this.window = new SendWindow({ limit: options.maxOutstanding, log: options.log }); ++ } ++ ++ canCarry(): boolean { ++ return this.link.isUp() && !this.transport.sock.destroyed; ++ } ++ ++ /** The link is gone, and every answer still owed on it with it. */ ++ linkLost(): void { ++ this.pending.settleAll(new Error('Session closed before a response arrived')); ++ } ++ ++ /** Hands a response to the request waiting for it. False means nothing was. */ ++ deliver(pduObj: PduObject): boolean { ++ return this.pending.deliver(pduObj); ++ } ++ ++ /** A response the codec refused settles its request instead of leaving it to time out. */ ++ settleRefused(seqNr: number, err: Error): void { ++ this.pending.settle(seqNr, { err }); ++ } ++ ++ /** Resolves 0 once nothing is left on the wire or queued for it, or with how much still is. */ ++ idle(timeout: number, signal: AbortSignal | undefined): Promise { ++ return this.window.idle(timeout, signal); ++ } ++ ++ /** A request on a bound link with a free slot reaches the socket before this returns. */ ++ request(input: PduObjectInput, options: SendOptions, lane: Lane): Promise> { ++ // Before the drain, the link and the window: a call that can never go out waits for none of them. ++ const refused = misuse(input) ?? (options.signal?.aborted === true ? abortedBeforeSend() : undefined); ++ ++ if (refused) return Promise.resolve({ err: refused }); ++ ++ if (lane === 'link') return this.attempt(input, options).then(attempt => attempt.result); ++ ++ if (lane === 'message') { ++ const shut = this.link.refusal() ?? (this.link.isStopped() ? new Error('Session is shutting down') : undefined); ++ ++ if (shut) return Promise.resolve({ err: shut }); ++ } ++ ++ return this.carry(input, options); ++ } ++ ++ private async carry(input: PduObjectInput, options: SendOptions): Promise> { ++ const budget = this.link.budget(options.signal); ++ ++ for (;;) { ++ if (!this.link.isUp()) { ++ const held = await budget(); ++ ++ if (held.err) return { err: held.err }; ++ } ++ ++ if (!this.window.take()) { ++ const slot = await this.window.wait(options.signal); ++ ++ if (slot.err) return { err: slot.err }; ++ } ++ ++ const attempt = await this.attempt(input, options).finally(() => { this.window.release(); }); ++ ++ // Until the link is dropped it admits the retry straight back onto the dead socket, and the loop spins. ++ if (!attempt.retry || !this.link.awaitsNextLink()) return attempt.result; ++ } ++ } ++ ++ private async attempt(input: PduObjectInput, options: SendOptions): Promise { ++ // pending.wait() alone settles the caller while the request still goes out to the peer. ++ if (options.signal?.aborted === true) { ++ return { result: { err: abortedBeforeSend() }, retry: false }; ++ } ++ ++ const seqNr = this.pending.nextSeqNr(); ++ const built = objToPdu({ ...input, seqNr }); ++ ++ if (built.err) return { result: { err: built.err }, retry: false }; ++ ++ const response = this.pending.wait(seqNr, { ++ signal: options.signal, ++ timeout: this.responseTimeout, ++ }); ++ const written = this.transport.write(built.buffer); ++ ++ if (written.err) { ++ this.pending.settle(seqNr, { err: written.err }); ++ ++ return { result: { err: written.err }, retry: true }; ++ } ++ ++ const answered = await response; ++ ++ // It went out, so a failure now means the peer may have taken it and the answer was the loss. ++ return { result: answered.err ? { err: new UnansweredError(answered.err) } : answered, retry: false }; ++ } ++} +diff --git a/src/pdu-transport.ts b/src/session/pdu-transport.ts +similarity index 90% +rename from src/pdu-transport.ts +rename to src/session/pdu-transport.ts +index 966cdee..b559990 100644 +--- a/src/pdu-transport.ts ++++ b/src/session/pdu-transport.ts +@@ -1,10 +1,10 @@ +-import type { PduObject } from './pdu.ts'; +-import type { SmppLog } from './log.ts'; ++import type { PduObject } from '../wire/pdu.ts'; ++import type { SmppLog } from '../log.ts'; + import type { Socket } from 'node:net'; +-import type { VoidResult } from './result.ts'; +-import { PduFramer } from './pdu-framer.ts'; +-import { PduRefusedError } from './pdu-refusal.ts'; +-import { pduToObj } from './pdu.ts'; ++import type { VoidResult } from '../result.ts'; ++import { PduFramer } from '../wire/pdu-framer.ts'; ++import { PduRefusedError } from '../wire/pdu-refusal.ts'; ++import { pduToObj } from '../wire/pdu.ts'; + + export type PduTransportOptions = { + log: SmppLog; +diff --git a/src/pending-requests.ts b/src/session/pending-requests.ts +similarity index 92% +rename from src/pending-requests.ts +rename to src/session/pending-requests.ts +index 7949106..b6bdff9 100644 +--- a/src/pending-requests.ts ++++ b/src/session/pending-requests.ts +@@ -1,7 +1,7 @@ +-import type { PduObject } from './pdu.ts'; +-import type { Result } from './result.ts'; +-import type { SmppLog } from './log.ts'; +-import { maxSeqNr } from './pdu.ts'; ++import type { PduObject } from '../wire/pdu.ts'; ++import type { Result } from '../result.ts'; ++import type { SmppLog } from '../log.ts'; ++import { maxSeqNr } from '../wire/pdu.ts'; + + export type WaitOptions = { + signal?: AbortSignal | undefined; +diff --git a/src/reconnect-loop.ts b/src/session/reconnect-loop.ts +similarity index 88% +rename from src/reconnect-loop.ts +rename to src/session/reconnect-loop.ts +index af8044d..a59431b 100644 +--- a/src/reconnect-loop.ts ++++ b/src/session/reconnect-loop.ts +@@ -1,11 +1,8 @@ +-import type { Result, VoidResult } from './result.ts'; +-import type { SmppLog } from './log.ts'; ++import type { Result, VoidResult } from '../result.ts'; ++import type { SmppLog } from '../log.ts'; + import type { Socket } from 'node:net'; +- +-export const backoffDefaults = { +- maxDelay: 30_000, +- minDelay: 1000, +-}; ++import { defaults } from '../defaults.ts'; ++import { errorFrom } from '../error-from.ts'; + + export type ReconnectLoopOptions = { + connect: () => Promise>; +@@ -32,8 +29,8 @@ export class ReconnectLoop { + private upAt: number | undefined; + + constructor(options: ReconnectLoopOptions) { +- this.maxDelay = options.maxDelay ?? backoffDefaults.maxDelay; +- this.minDelay = options.minDelay ?? backoffDefaults.minDelay; ++ this.maxDelay = options.maxDelay ?? defaults.maxDelay; ++ this.minDelay = options.minDelay ?? defaults.minDelay; + this.now = options.now ?? Date.now; + this.options = options; + this.delay = this.minDelay; +@@ -81,7 +78,7 @@ export class ReconnectLoop { + + // connect() and onConnected() are the application's, so a throw from either lands here. + const retry = await this.attempt().catch((thrown: unknown) => { +- const err = thrown instanceof Error ? thrown : new Error(String(thrown)); ++ const err = errorFrom(thrown); + + this.options.log.error('reconnect - an attempt threw', { message: err.message }); + +@@ -137,7 +134,7 @@ export class ReconnectLoop { + } catch (thrown: unknown) { + sock.destroy(); + +- return { err: thrown instanceof Error ? thrown : new Error(String(thrown)) }; ++ return { err: errorFrom(thrown) }; + } + } + } +diff --git a/src/session/running-handlers.ts b/src/session/running-handlers.ts +new file mode 100644 +index 0000000..5e15723 +--- /dev/null ++++ b/src/session/running-handlers.ts +@@ -0,0 +1,97 @@ ++import type { SmppLog } from '../log.ts'; ++import type { SmsHandler } from './session-options.ts'; ++import type { Sms } from '../messages/sms.ts'; ++import { IdleWaiters } from './idle-waiters.ts'; ++import { errorFrom } from '../error-from.ts'; ++ ++export type RunningHandlersOptions = { ++ log: SmppLog; ++ max: number; ++ /** What a handler that threw or rejected is reported through. */ ++ onFailure: (err: Error) => void; ++ /** A handler running this long is no longer counted or waited for. */ ++ timeout: number; ++}; ++ ++/** The `onSms` handlers in flight: the bound a peer is throttled at, and what a drain waits for. */ ++export class RunningHandlers { ++ private readonly idleWaiters = new IdleWaiters(); ++ private readonly options: RunningHandlersOptions; ++ private atBound = false; ++ private running = 0; ++ ++ constructor(options: RunningHandlersOptions) { ++ this.options = options; ++ } ++ ++ get size(): number { ++ return this.running; ++ } ++ ++ /** Whether a message arriving now has to be refused. Logs once each way the bound is crossed. */ ++ full(): boolean { ++ const { log, max } = this.options; ++ ++ if (this.running >= max) { ++ if (!this.atBound) { ++ this.atBound = true; ++ log.warn('session - handlers at their bound, refusing messages until some finish', { running: this.running }); ++ } ++ ++ return true; ++ } ++ ++ // Half, so a peer keeping its window full does not flip this on every finished handler. ++ if (this.atBound && this.running <= max / 2) { ++ this.atBound = false; ++ log.info('session - handlers down to half their bound, accepting messages again', { running: this.running }); ++ } ++ ++ return false; ++ } ++ ++ /** Runs the handler and counts it until it settles or its deadline passes. */ ++ run(sms: Sms, handler: SmsHandler): void { ++ const { log, timeout } = this.options; ++ let counted = true; ++ const finished = (): void => { ++ if (!counted) return; ++ ++ counted = false; ++ this.running--; ++ ++ if (this.running === 0) this.idleWaiters.settle(); ++ }; ++ const expired = setTimeout(() => { ++ log.warn('session - a handler still running past its deadline is no longer waited for', { timeout }); ++ finished(); ++ }, timeout); ++ ++ expired.unref(); ++ this.running++; ++ ++ // A handler that throws is the application's bug; it must not become ours. Hard rule 1. ++ Promise.resolve().then(() => handler(sms)).then( ++ () => undefined, ++ (thrown: unknown) => { ++ const err = errorFrom(thrown); ++ ++ log.error('session - an onSms handler failed', { message: err.message }); ++ this.options.onFailure(err); ++ }, ++ ).finally(() => { ++ clearTimeout(expired); ++ finished(); ++ }).catch(() => undefined); ++ } ++ ++ /** Resolves 0 once every handler has finished, or with how many have not. */ ++ idle(timeout: number, signal: AbortSignal | undefined): Promise { ++ return this.idleWaiters.wait(() => this.running, timeout, signal); ++ } ++ ++ /** The session is over: whatever still runs is the application's, and no drain waits on it. */ ++ release(): void { ++ this.idleWaiters.settle(); ++ } ++} +diff --git a/src/send-window.ts b/src/session/send-window.ts +similarity index 77% +rename from src/send-window.ts +rename to src/session/send-window.ts +index e13d67d..e0f8984 100644 +--- a/src/send-window.ts ++++ b/src/session/send-window.ts +@@ -1,5 +1,5 @@ +-import type { SmppLog } from './log.ts'; +-import type { VoidResult } from './result.ts'; ++import type { SmppLog } from '../log.ts'; ++import type { VoidResult } from '../result.ts'; + import { IdleWaiters } from './idle-waiters.ts'; + + export type SendWindowOptions = { +@@ -26,17 +26,39 @@ export class SendWindow { + this.log = options.log; + } + +- /** Resolves once a slot is the caller's, or with the reason it stopped waiting for one. */ +- acquire(signal: AbortSignal | undefined): Promise { +- if (this.inFlight < this.limit) { +- this.inFlight++; ++ /** Takes a slot on the spot. False means the window is full and `wait()` is the way in. */ ++ take(): boolean { ++ if (this.inFlight >= this.limit) return false; + +- return Promise.resolve({}); +- } ++ this.inFlight++; ++ ++ return true; ++ } + ++ /** Resolves once a freed slot is the caller's, or with the reason it stopped waiting for one. */ ++ wait(signal: AbortSignal | undefined): Promise { + if (signal?.aborted === true) return Promise.resolve({ err: aborted() }); + +- return this.queue(signal); ++ this.log.verbose('sendWindow - queueing a request behind a full window', { ++ limit: this.limit, ++ queued: this.waiting.size + 1, ++ }); ++ ++ // A waiter leaves the queue as it settles, so release() can only hand a slot to one still in it. ++ return new Promise(resolve => { ++ const settle = (result: VoidResult): void => { ++ this.waiting.delete(settle); ++ signal?.removeEventListener('abort', onAbort); ++ resolve(result); ++ }; ++ ++ function onAbort(): void { ++ settle({ err: aborted() }); ++ } ++ ++ signal?.addEventListener('abort', onAbort, { once: true }); ++ this.waiting.add(settle); ++ }); + } + + release(): void { +@@ -65,27 +87,4 @@ export class SendWindow { + idle(timeout: number, signal: AbortSignal | undefined): Promise { + return this.idleWaiters.wait(() => this.unfinished(), timeout, signal); + } +- +- /** A waiter leaves the queue as it settles, so release() can only hand a slot to one still in it. */ +- private queue(signal: AbortSignal | undefined): Promise { +- this.log.verbose('sendWindow - queueing a request behind a full window', { +- limit: this.limit, +- queued: this.waiting.size + 1, +- }); +- +- return new Promise(resolve => { +- const settle = (result: VoidResult): void => { +- this.waiting.delete(settle); +- signal?.removeEventListener('abort', onAbort); +- resolve(result); +- }; +- +- function onAbort(): void { +- settle({ err: aborted() }); +- } +- +- signal?.addEventListener('abort', onAbort, { once: true }); +- this.waiting.add(settle); +- }); +- } + } +diff --git a/src/session-options.ts b/src/session/session-options.ts +similarity index 62% +rename from src/session-options.ts +rename to src/session/session-options.ts +index 0b768c9..b9f36ff 100644 +--- a/src/session-options.ts ++++ b/src/session/session-options.ts +@@ -1,17 +1,16 @@ +-import type { Dlr } from './dlr.ts'; +-import type { MessageDlr } from './dlr-merger.ts'; +-import type { PduObject } from './pdu.ts'; +-import type { PduRefusedError } from './pdu-refusal.ts'; +-import type { Result, VoidResult } from './result.ts'; +-import type { Session } from './session.ts'; +-import type { SmppLog } from './log.ts'; +-import type { SmsIdFormat } from './sms-id.ts'; +-import type { Sms } from './sms.ts'; ++import type { Dlr } from '../messages/dlr.ts'; ++import type { MessageDlr } from '../messages/dlr-merger.ts'; ++import type { PduObject } from '../wire/pdu.ts'; ++import type { PduRefusedError } from '../wire/pdu-refusal.ts'; ++import type { Result, VoidResult } from '../result.ts'; ++import type { Session } from '../session.ts'; ++import type { SmppLog } from '../log.ts'; ++import type { SmsIdFormat } from '../messages/sms-id.ts'; ++import type { Sms } from '../messages/sms.ts'; + 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 { defaults } from '../defaults.ts'; ++import { isSmsIdNotation, smsIdNotations, smsIdPlaces } from '../messages/sms-id.ts'; ++import { namedValue } from '../error-from.ts'; + + export type SessionEvents = { + close: []; +@@ -23,56 +22,8 @@ export type SessionEvents = { + messageDlr: [MessageDlr]; + reconnected: []; + sessionError: [Error | PduRefusedError]; +- 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. */ +@@ -85,6 +36,12 @@ export type CloseOptions = { signal?: AbortSignal | undefined }; + */ + export type OnRequest = (session: Session, pduObj: PduObject) => Promise | boolean; + ++/** ++ * Every message the peer sends, already answered. A shutdown waits for the promise it returns; ++ * one that throws or rejects reaches `sessionError`. ++ */ ++export type SmsHandler = (sms: Sms) => unknown; ++ + /** + * How to come back after an unexpected disconnect. The session owns the retry loop; the caller + * supplies how to open a socket and what to do once it is open (bind, for a client). +@@ -104,10 +61,12 @@ export type SessionOptions = { + maxOutstanding?: number | undefined; + maxReassembly?: number | undefined; + onRequest?: OnRequest | undefined; ++ /** Without one, every message the peer sends is refused as unwanted. */ ++ onSms?: SmsHandler | undefined; + reassemblyTimeout?: number | undefined; + reconnect?: ReconnectOptions | undefined; + responseTimeout?: number | undefined; +- /** How long a drain waits for the requests already on the wire. 0 waits forever. */ ++ /** How long a drain waits for the handlers still running and the requests already on the wire. 0 waits forever. */ + shutdownTimeout?: number | undefined; + /** The notation the peer writes message ids in, where it is not the one they are compared in. */ + smsIdFormat?: SmsIdFormat | undefined; +@@ -116,56 +75,10 @@ export type SessionOptions = { + systemId?: string | undefined; + }; + +-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, +- /** The peer gave up on an unanswered message long before this; the bound is against growth. */ +- heldMessageTimeout: 300_000, +- maxDlrMerges: 1000, +- maxHeldMessages: 1000, +- maxHeldOctets: 64 * 1024 * 1024, +- maxOutstanding: 10, +- maxReassembly: 1000, +- reassemblyTimeout: 300_000, +- responseTimeout: 30_000, +- shutdownTimeout: 5000, +- systemId: defaultSystemId, +-}; +- +-/** +- * A count below 1 does not fail loudly anywhere downstream: `maxOutstanding: 0` leaves every send +- * queued behind a slot that is never freed, so a send with no `signal` never settles at all. +- */ + export function checkSessionOptions(options: CheckableOptions): VoidResult { + if (options.fromStart !== undefined) { + return { err: new Error('fromStart is part of the reconnect policy, spell it reconnect: { fromStart: true }') }; +@@ -175,6 +88,10 @@ export function checkSessionOptions(options: CheckableOptions): VoidResult { + + if (connect.err) return connect; + ++ const hooks = checkHooks(options); ++ ++ if (hooks.err) return hooks; ++ + const checked = checkLimits(limitsOf(options)); + + if (checked.err) return checked; +@@ -184,10 +101,27 @@ export function checkSessionOptions(options: CheckableOptions): VoidResult { + return backoff.err ? backoff : checkSmsIdFormat(options.smsIdFormat); + } + ++/** A caller without types would otherwise reach a TypeError once per PDU rather than once here. */ ++function checkHooks(options: CheckableOptions): VoidResult { ++ for (const name of ['authenticate', 'onRequest', 'onSms'] as const) { ++ const hook = options[name]; ++ ++ if (hook !== undefined && typeof hook !== 'function') { ++ return { err: new Error(`${name} must be a function, got ${typeof hook}`) }; ++ } ++ } ++ ++ return {}; ++} ++ ++/** ++ * A count below 1 does not fail loudly anywhere downstream: `maxOutstanding: 0` leaves every send ++ * queued behind a slot that is never freed, so a send with no `signal` never settles at all. ++ */ + function limitsOf(options: CheckableOptions): [string, number, number][] { + return [ + ['idleTimeout', options.idleTimeout ?? 0, 0], +- ['maxOctets', options.maxOctets ?? defaultMaxOctets, 1], ++ ['maxOctets', options.maxOctets ?? defaults.maxOctets, 1], + ['maxOutstanding', options.maxOutstanding ?? defaults.maxOutstanding, 1], + ['maxReassembly', options.maxReassembly ?? defaults.maxReassembly, 1], + ['reassemblyTimeout', options.reassemblyTimeout ?? defaults.reassemblyTimeout, 0], +@@ -243,8 +177,8 @@ function checkReconnect(reconnect: unknown): VoidResult { + return { err: new Error(`reconnect.fromStart must be true or false, got ${typeof reconnect.fromStart}`) }; + } + +- const maxDelay = delayOr(reconnect.maxDelay, backoffDefaults.maxDelay); +- const minDelay = delayOr(reconnect.minDelay, backoffDefaults.minDelay); ++ const maxDelay = delayOr(reconnect.maxDelay, defaults.maxDelay); ++ const minDelay = delayOr(reconnect.minDelay, defaults.minDelay); + // A delay of 0 never doubles, so the backoff never starts and every retry lands at once. + const checked = checkLimits([['maxDelay', maxDelay, 1], ['minDelay', minDelay, 1]]); + +@@ -292,6 +226,7 @@ function checkSmsIdFormat(smsIdFormat: unknown): VoidResult { + + /** What the checker reads, as it arrives: a caller without types can put anything in it. */ + export type CheckableOptions = { ++ authenticate?: unknown; + connectTimeout?: unknown; + /** Not an option: the one spelling is inside reconnect, and this is where the other is refused. */ + fromStart?: unknown; +@@ -299,6 +234,8 @@ export type CheckableOptions = { + maxOctets?: number | undefined; + maxOutstanding?: number | undefined; + maxReassembly?: number | undefined; ++ onRequest?: unknown; ++ onSms?: unknown; + reassemblyTimeout?: number | undefined; + reconnect?: unknown; + responseTimeout?: number | undefined; +diff --git a/src/sms.ts b/src/sms.ts +deleted file mode 100644 +index 5149ceb..0000000 +--- a/src/sms.ts ++++ /dev/null +@@ -1,243 +0,0 @@ +-import type { ErrorName } from './defs/errors.ts'; +-import type { MessageState } from './defs/constants.ts'; +-import type { PduObject, PduObjectInput, TlvInputs } from './pdu.ts'; +-import type { Result, VoidResult } from './result.ts'; +-import type { Session } from './session.ts'; +-import { UnansweredError } from './unanswered-error.ts'; +-import { consts } from './defs/constants.ts'; +-import { decodeSegments } from './reassembly.ts'; +-import { messageClassOf } from './defs/encodings.ts'; +-import { paramText } from './defs/types.ts'; +-import { receiptCodes, transientStates } from './dlr.ts'; +-import { smppDate } from './message.ts'; +-import { respIdParams, segmentId } from './sms-id.ts'; +-import { uuidv7 } from './uuid.ts'; +- +-/** `pduObjs` holds what the peer took, so a partial failure names what is already receipted. */ +-export type SendDlrResult = { +- err?: Error; +- pduObjs: PduObject[]; +- /** Segments that went out unanswered. The peer may have taken them, so sending again may duplicate. */ +- unanswered: number; +-}; +- +-export type SendRespOptions = { +- /** The id the peer correlates a later delivery receipt by. Defaults to a generated UUID v7. */ +- smsId?: string; +- status?: ErrorName; +-}; +- +-/** +- * A received SMS, and the handle for answering it. Multipart messages arrive as one Sms carrying +- * every segment's PDU. +- */ +-export type Sms = { +- /** +- * Whether the peer was answered as the message's segments arrived, which is what a concatenated +- * message needs and a segment count cannot tell you. `sendResp()` then writes nothing. +- */ +- answeredOnArrival: boolean; +- dlr: boolean; +- /** GSM 03.38 message class 0: shown on arrival and not stored. */ +- flash: boolean; +- from: string; +- message: string; +- pduObjs: PduObject[]; +- /** Sends a delivery report back to the sender. Defaults to DELIVERED. */ +- sendDlr: (status?: MessageState) => Promise; +- /** +- * Answers the message, and says the application is done with it. A concatenated message was +- * answered segment by segment as it arrived, so there it only releases a shutdown's wait and +- * refuses an `smsId` or a refusing `status`. Part of the protocol, not optional. +- */ +- sendResp: (options?: SendRespOptions) => Promise; +- session: Session; +- /** The id the segments were answered with, the id `sendResp()` was given, or a generated UUID v7. */ +- readonly smsId: string; +- submitTime: Date; +- to: string; +-}; +- +-export type SmsInput = { +- /** The id base the segments were already answered with; absent leaves the answer to `sendResp()`. */ +- answeredAs?: string | undefined; +- pduObjs: PduObject[]; +- session: Session; +-}; +- +-export type SmsHandlers = { +- answered: () => void; +- lostLink: () => boolean; +- send: (input: PduObjectInput) => Promise>; +-}; +- +-/** GSM 03.38 section 4 gives class 0 immediate display; every other class is stored somewhere. */ +-const immediateDisplayClass = 0; +- +-export function createSms(input: SmsInput, handlers: SmsHandlers): Sms { +- const first = input.pduObjs[0]; +- const registered = first?.params.registered_delivery; +- const dataCoding = first?.params.data_coding; +- const answered = { smsId: input.answeredAs ?? uuidv7() }; +- +- const sms: Sms = { +- answeredOnArrival: input.answeredAs !== undefined, +- dlr: typeof registered === 'number' && registered !== 0, +- flash: typeof dataCoding === 'number' && messageClassOf(dataCoding) === immediateDisplayClass, +- from: paramText(first?.params.source_addr), +- message: decodeSegments(input.pduObjs), +- pduObjs: input.pduObjs, +- sendDlr: status => sendDlr(sms, input.session, handlers, status), +- sendResp: options => (input.answeredAs === undefined +- ? sendResp(sms, input.session, answered, options ?? {}, handlers) +- : answeredOnArrival(options ?? {}, handlers)), +- session: input.session, +- get smsId(): string { +- return answered.smsId; +- }, +- submitTime: new Date(), +- to: paramText(first?.params.destination_addr), +- }; +- +- return sms; +-} +- +-/** Every segment went out answered, so the call is what the shutdown waits for and nothing else. */ +-function answeredOnArrival( +- options: SendRespOptions, +- handlers: Pick, +-): Promise { +- if (options.smsId !== undefined) { +- return Promise.resolve({ +- err: new Error('This message\'s id was fixed when its first segment arrived; read sms.smsId'), +- }); +- } +- +- if (options.status !== undefined && options.status !== 'ESME_ROK') { +- return Promise.resolve({ +- err: new Error('Its segments were answered as they arrived, so there is nothing left to refuse; refuse a segment from the onRequest option instead'), +- }); +- } +- +- handlers.answered(); +- +- return Promise.resolve({}); +-} +- +-async function sendResp( +- sms: Sms, +- session: Session, +- answered: { smsId: string }, +- options: SendRespOptions, +- handlers: Pick, +-): Promise { +- const total = sms.pduObjs.length; +- +- if (total === 0) { +- return { err: new Error('No PDUs to answer') }; +- } +- +- if (options.smsId === '') { +- return { err: new Error('smsId must not be empty') }; +- } +- +- if (options.smsId !== undefined) answered.smsId = options.smsId; +- +- // A response carries the sequence number it was asked on, which the next link knows nothing about. +- if (handlers.lostLink()) { +- return { err: new Error('The link this message arrived on is gone, so nothing would correlate the response') }; +- } +- +- const results = await Promise.all(sms.pduObjs.map((pduObj, index) => session.sendReturn( +- pduObj, +- options.status ?? 'ESME_ROK', +- respIdParams(pduObj.cmdName, segmentId(answered.smsId, index, total)), +- ))); +- +- const failure = results.find(result => result.err); +- +- if (!failure) handlers.answered(); +- +- return failure ?? {}; +-} +- +-/** The receipt as text, which is all of it a peer below SMPP 3.4 is allowed to be sent. */ +-function receiptText(sms: Sms, smsId: string, status: MessageState): string { +- const delivered = status === 'DELIVERED'; +- const failed = !delivered && !transientStates.includes(status); +- +- return [ +- `id:${smsId}`, +- 'sub:001', +- `dlvrd:${delivered ? '001' : '000'}`, +- `submit date:${smppDate(sms.submitTime)}`, +- `done date:${smppDate(new Date())}`, +- `stat:${receiptCodes[status]}`, +- `err:${failed ? '001' : '000'}`, +- 'text:', +- ].join(' '); +-} +- +-function receiptTlvs(smsId: string, status: MessageState): TlvInputs { +- return { +- message_state: { tagValue: consts.MESSAGE_STATE[status] }, +- receipted_message_id: { tagValue: smsId }, +- }; +-} +- +-function collectReceipt(sent: Result<{ pduObj: PduObject }>[]): SendDlrResult { +- const pduObjs: PduObject[] = []; +- let failure: Error | undefined; +- let unanswered = 0; +- +- for (const one of sent) { +- if (one.err) { +- if (one.err instanceof UnansweredError) unanswered++; +- +- failure ??= one.err; +- } else if (one.pduObj.cmdStatus === 'ESME_ROK') { +- pduObjs.push(one.pduObj); +- } else { +- const refusal = one.pduObj.cmdStatus ?? String(one.pduObj.cmdStatusId); +- +- failure ??= new Error(`deliver_sm refused by the peer: ${refusal}`); +- } +- } +- +- return failure ? { err: failure, pduObjs, unanswered } : { pduObjs, unanswered }; +-} +- +-async function sendDlr( +- sms: Sms, +- session: Session, +- handlers: Pick, +- status: MessageState = 'DELIVERED', +-): Promise { +- if (!session.bindAllows('deliver_sm')) { +- return { +- err: new Error('A transmitter-bound session does not carry deliver_sm'), +- pduObjs: [], +- unanswered: 0, +- }; +- } +- +- const total = sms.pduObjs.length; +- // Together, not one after a response: a drain waiting for this message must see the whole receipt. +- const sent = await Promise.all(sms.pduObjs.map((_segment, index) => { +- const smsId = segmentId(sms.smsId, index, total); +- +- return handlers.send({ +- cmdName: 'deliver_sm', +- params: { +- destination_addr: sms.from, +- esm_class: transientStates.includes(status) +- ? consts.ESM_CLASS.INTERMEDIATE_DELIVERY +- : consts.ESM_CLASS.MC_DELIVERY_RECEIPT, +- short_message: receiptText(sms, smsId, status), +- source_addr: sms.to, +- }, +- ...(session.acceptsOptionalParams() ? { tlvs: receiptTlvs(smsId, status) } : {}), +- }); +- })); +- return collectReceipt(sent); +-} +diff --git a/src/pdu-framer.ts b/src/wire/pdu-framer.ts +similarity index 97% +rename from src/pdu-framer.ts +rename to src/wire/pdu-framer.ts +index 1b23dea..4aa021a 100644 +--- a/src/pdu-framer.ts ++++ b/src/wire/pdu-framer.ts +@@ -1,4 +1,4 @@ +-import type { Result } from './result.ts'; ++import type { Result } from '../result.ts'; + import { framingRefusal } from './pdu-refusal.ts'; + + /** +diff --git a/src/pdu-refusal.ts b/src/wire/pdu-refusal.ts +similarity index 92% +rename from src/pdu-refusal.ts +rename to src/wire/pdu-refusal.ts +index 45b7a2a..44362c5 100644 +--- a/src/pdu-refusal.ts ++++ b/src/wire/pdu-refusal.ts +@@ -1,6 +1,6 @@ +-import type { CommandName } from './defs/commands.ts'; +-import type { ErrorName } from './defs/errors.ts'; +-import { respNameFor } from './defs/commands.ts'; ++import type { CommandName } from '../defs/commands.ts'; ++import type { ErrorName } from '../defs/errors.ts'; ++import { respNameFor } from '../defs/commands.ts'; + + /** A hostile peer must not be able to make us allocate arbitrarily. */ + export const maxPduLength = 1024 * 1024; +diff --git a/src/pdu.ts b/src/wire/pdu.ts +similarity index 95% +rename from src/pdu.ts +rename to src/wire/pdu.ts +index 743cbed..0ad7171 100644 +--- a/src/pdu.ts ++++ b/src/wire/pdu.ts +@@ -1,16 +1,16 @@ +-import type { CommandDefinition, CommandName, PduParams, PduParamsInput } from './defs/commands.ts'; +-import type { ErrorName } from './defs/errors.ts'; +-import type { ParamValue } from './defs/types.ts'; ++import type { CommandDefinition, CommandName, PduParams, PduParamsInput } from '../defs/commands.ts'; ++import type { ErrorName } from '../defs/errors.ts'; ++import type { ParamValue } from '../defs/types.ts'; + import type { PduHeader } from './pdu-refusal.ts'; +-import type { Result, VoidResult } from './result.ts'; +-import type { TlvInputs, Tlvs } from './defs/tlvs.ts'; ++import type { Result, VoidResult } from '../result.ts'; ++import type { TlvInputs, Tlvs } from '../defs/tlvs.ts'; + import { PduRefusedError, framingRefusal } from './pdu-refusal.ts'; +-import { cmds, commandNameById, respNameFor } from './defs/commands.ts'; +-import { hasUdh } from './defs/constants.ts'; +-import { decodeMessage, encodeBody } from './message.ts'; +-import { errorNameById, errors, isErrorName } from './defs/errors.ts'; +-import { paramNumber, valueText } from './defs/types.ts'; +-import { parseTlvs, writeTlvs } from './defs/tlvs.ts'; ++import { cmds, commandNameById, respNameFor } from '../defs/commands.ts'; ++import { hasUdh } from '../defs/constants.ts'; ++import { decodeMessage, encodeBody } from '../messages/message.ts'; ++import { errorNameById, errors, isErrorName } from '../defs/errors.ts'; ++import { paramNumber, valueText } from '../defs/types.ts'; ++import { parseTlvs, writeTlvs } from '../defs/tlvs.ts'; + + /** The highest sequence number this library hands out; SMPP 3.4 4.7.1 reserves 0x7fffffff. */ + export const maxSeqNr = 2147483646; +diff --git a/test/declared-alphabet.test.ts b/test/declared-alphabet.test.ts +index 8132883..44200d6 100644 +--- a/test/declared-alphabet.test.ts ++++ b/test/declared-alphabet.test.ts +@@ -4,13 +4,13 @@ import { bindToSmsc, dummySmsc } from './dummy-smsc.ts'; + import { client } from '../src/client.ts'; + import { closeAfter } from './teardown.ts'; + import { consts } from '../src/defs/constants.ts'; +-import { decodeMessage } from '../src/message.ts'; +-import { dlrFromPdu } from '../src/dlr.ts'; ++import { decodeMessage } from '../src/messages/message.ts'; ++import { dlrFromPdu } from '../src/messages/dlr.ts'; + import { encodingByDataCoding, encodings } from '../src/defs/encodings.ts'; +-import { objToPdu, pduToObj } from '../src/pdu.ts'; ++import { objToPdu, pduToObj } from '../src/wire/pdu.ts'; + import { paramNumber } from '../src/defs/types.ts'; + import { server } from '../src/server.ts'; +-import type { PduObject } from '../src/pdu.ts'; ++import type { PduObject } from '../src/wire/pdu.ts'; + + const from = '46701113311'; + const to = '46709771337'; +@@ -21,7 +21,7 @@ const bothTables = 'Cost 5$ @home'; + /** What SMPP 3.4 5.2.19 assigns each coding, read the way a peer honouring the field reads it. */ + const byTheSpecsTable: Record string> = { + // The SMSC default alphabet, which every peer in interop-tests/ runs as GSM 03.38. +- 0x00: octets => encodings.ASCII.decode(octets), ++ 0x00: octets => encodings.GSM7.decode(octets), + // IA5 (CCITT T.50), whose whole range is what Latin-1 reads below 0x80. + [consts.ENCODING.IA5]: octets => octets.toString('latin1'), + }; +@@ -121,17 +121,15 @@ describe('the alphabet a message declares is the one its octets are written in', + + // sendDlr() writes its body as a string with no data_coding, so it takes the detected branch too. + test('declares 0x00 on a receipt it writes itself', async t => { +- const { err, server: smpp } = await server({ port: 0 }); ++ const { err, server: smpp } = await server({ ++ onSms: sms => sms.sendDlr('DELIVERED'), ++ port: 0, ++ }); + + assert.equal(err, undefined); + assert.ok(smpp); + closeAfter(t, smpp); + +- smpp.on('session', peer => peer.on('sms', async sms => { +- await sms.sendResp(); +- await sms.sendDlr('DELIVERED'); +- })); +- + const connected = await client({ port: smpp.port, reconnect: false }); + + assert.equal(connected.err, undefined); +@@ -166,7 +164,7 @@ describe('the alphabet a message declares is the one its octets are written in', + + describe('what a peer declares is read as generously as it was before', () => { + test('reads data_coding 0x01 as GSM 03.38, as 0x00 is read', () => { +- assert.equal(encodingByDataCoding(0x01), 'ASCII'); ++ assert.equal(encodingByDataCoding(0x01), 'GSM7'); + + const octets = Buffer.from('436f73742035022000686f6d65', 'hex'); + +diff --git a/test/dlr.test.ts b/test/dlr.test.ts +index fa6ec84..a1cc2b8 100644 +--- a/test/dlr.test.ts ++++ b/test/dlr.test.ts +@@ -1,10 +1,10 @@ + import assert from 'node:assert/strict'; + import test, { describe } from 'node:test'; + import { consts } from '../src/defs/constants.ts'; +-import { dlrFromPdu, parseReceipt, receiptCodes } from '../src/dlr.ts'; +-import { encodeMessage } from '../src/message.ts'; +-import { objToPdu, pduToObj } from '../src/pdu.ts'; +-import type { PduObject, TlvInputs } from '../src/pdu.ts'; ++import { dlrFromPdu, parseReceipt, receiptCodes } from '../src/messages/dlr.ts'; ++import { encodeMessage } from '../src/messages/message.ts'; ++import { objToPdu, pduToObj } from '../src/wire/pdu.ts'; ++import type { PduObject, TlvInputs } from '../src/wire/pdu.ts'; + + const receiptText = 'id:0195f0c7 sub:001 dlvrd:001 submit date:2508251430 done date:2508251431 stat:DELIVRD err:000 text:hello there'; + +diff --git a/test/dummy-smsc.ts b/test/dummy-smsc.ts +index 7da2609..5649ba2 100644 +--- a/test/dummy-smsc.ts ++++ b/test/dummy-smsc.ts +@@ -2,12 +2,12 @@ import assert from 'node:assert/strict'; + import net from 'node:net'; + import type { Session } from '../src/session.ts'; + import type { TestContext } from 'node:test'; +-import { PduFramer } from '../src/pdu-framer.ts'; ++import { PduFramer } from '../src/wire/pdu-framer.ts'; + import { client } from '../src/client.ts'; + import { closeAfter, closeListenerAfter } from './teardown.ts'; + import { consts } from '../src/defs/constants.ts'; +-import { objToPdu, pduReturn, pduToObj } from '../src/pdu.ts'; +-import { uuidv7 } from '../src/uuid.ts'; ++import { objToPdu, pduReturn, pduToObj } from '../src/wire/pdu.ts'; ++import { uuidv7 } from '../src/messages/uuid.ts'; + + export type DummySmsc = { + /** Writes a delivery receipt to the ESME, its body spelled as the test names it. */ +diff --git a/test/encodings.test.ts b/test/encodings.test.ts +index 67fc68c..c064c39 100644 +--- a/test/encodings.test.ts ++++ b/test/encodings.test.ts +@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; + import test, { describe } from 'node:test'; + import { dataCodingByEncoding, detect, encodingByDataCoding, encodings, isEncodingName, unencodable } from '../src/defs/encodings.ts'; + +-describe('ASCII (GSM 03.38)', () => { ++describe('GSM7 (GSM 03.38)', () => { + const samples: [string, number[]][] = [ + ['@£$¥', [0, 1, 2, 3]], + [' 1a=', [0x20, 0x31, 0x61, 0x3D]], +@@ -10,23 +10,23 @@ describe('ASCII (GSM 03.38)', () => { + ]; + + test('matches strings encodable in the GSM 03.38 charset', () => { +- assert.ok(encodings.ASCII.match('')); +- assert.ok(encodings.ASCII.match('@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ\x1BÆæßÉ !"#¤%&\'')); +- assert.ok(encodings.ASCII.match('()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZ')); +- assert.ok(encodings.ASCII.match('ÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà')); +- assert.ok(encodings.ASCII.match('\f^{}\\[~]|€')); ++ assert.ok(encodings.GSM7.match('')); ++ assert.ok(encodings.GSM7.match('@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ\x1BÆæßÉ !"#¤%&\'')); ++ assert.ok(encodings.GSM7.match('()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZ')); ++ assert.ok(encodings.GSM7.match('ÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà')); ++ assert.ok(encodings.GSM7.match('\f^{}\\[~]|€')); + }); + + test('rejects strings outside the GSM 03.38 charset', () => { +- assert.ok(!encodings.ASCII.match('`')); +- assert.ok(!encodings.ASCII.match('ÁáçÚUÓO')); +- assert.ok(!encodings.ASCII.match('تست')); ++ assert.ok(!encodings.GSM7.match('`')); ++ assert.ok(!encodings.GSM7.match('ÁáçÚUÓO')); ++ assert.ok(!encodings.GSM7.match('تست')); + }); + + test('round-trips the sample strings', () => { + for (const [str, bytes] of samples) { +- assert.deepEqual(encodings.ASCII.encode(str), Buffer.from(bytes)); +- assert.equal(encodings.ASCII.decode(Buffer.from(bytes)), str); ++ assert.deepEqual(encodings.GSM7.encode(str), Buffer.from(bytes)); ++ assert.equal(encodings.GSM7.decode(Buffer.from(bytes)), str); + } + }); + }); +@@ -97,8 +97,8 @@ describe('UCS2', () => { + + describe('detect()', () => { + test('picks the narrowest encoding that fits the string', () => { +- assert.equal(detect(''), 'ASCII'); +- assert.equal(detect('ÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà(){}[]'), 'ASCII'); ++ assert.equal(detect(''), 'GSM7'); ++ assert.equal(detect('ÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà(){}[]'), 'GSM7'); + assert.equal(detect('`ÁáçÚUÓO'), 'UCS2'); + assert.equal(detect('«©®µ¶±»'), 'UCS2'); + assert.equal(detect('ʹʺʻʼʽ`'), 'UCS2'); +@@ -122,16 +122,16 @@ describe('detect()', () => { + describe('unencodable()', () => { + test('names the first character an alphabet cannot carry, and nothing where it carries them all', () => { + assert.deepEqual(unencodable('あいう', 'LATIN1'), { char: 'あ', index: 0 }); +- assert.deepEqual(unencodable('Åsa naïve', 'ASCII'), { char: 'ï', index: 6 }); +- assert.equal(unencodable('€{}[]\\~^|\f', 'ASCII'), undefined); +- assert.equal(unencodable('Åsa', 'ASCII'), undefined); ++ assert.deepEqual(unencodable('Åsa naïve', 'GSM7'), { char: 'ï', index: 6 }); ++ assert.equal(unencodable('€{}[]\\~^|\f', 'GSM7'), undefined); ++ assert.equal(unencodable('Åsa', 'GSM7'), undefined); + assert.equal(unencodable('`ÁáçÚ', 'LATIN1'), undefined); + assert.equal(unencodable('あいう😀', 'UCS2'), undefined); + }); + + test('counts the index in the units the message is written in, so a surrogate pair reads back whole', () => { + assert.deepEqual(unencodable('ab😀', 'LATIN1'), { char: '😀', index: 2 }); +- assert.deepEqual(unencodable('a😀b', 'ASCII'), { char: '😀', index: 1 }); ++ assert.deepEqual(unencodable('a😀b', 'GSM7'), { char: '😀', index: 1 }); + }); + + test('carries every octet through Latin-1, which is what an 8-bit binary body is sent as', () => { +@@ -147,15 +147,15 @@ describe('unencodable()', () => { + }); + + test('reads a character at a time, so a bare GSM escape beside its base reads as carried', () => { +- assert.equal(unencodable('\x1Be', 'ASCII'), undefined); +- assert.deepEqual(encodings.ASCII.encode('\x1Be'), encodings.ASCII.encode('€')); ++ assert.equal(unencodable('\x1Be', 'GSM7'), undefined); ++ assert.deepEqual(encodings.GSM7.encode('\x1Be'), encodings.GSM7.encode('€')); + }); + }); + + describe('encodingByDataCoding()', () => { + test('resolves the flat SMPP data_coding table', () => { +- assert.equal(encodingByDataCoding(0x00), 'ASCII'); +- assert.equal(encodingByDataCoding(0x01), 'ASCII'); ++ assert.equal(encodingByDataCoding(0x00), 'GSM7'); ++ assert.equal(encodingByDataCoding(0x01), 'GSM7'); + assert.equal(encodingByDataCoding(0x08), 'UCS2'); + }); + +@@ -166,17 +166,17 @@ describe('encodingByDataCoding()', () => { + }); + + test('reads the alphabet bits when a message class is present', () => { +- assert.equal(encodingByDataCoding(0x10), 'ASCII'); +- assert.equal(encodingByDataCoding(0x11), 'ASCII'); ++ assert.equal(encodingByDataCoding(0x10), 'GSM7'); ++ assert.equal(encodingByDataCoding(0x11), 'GSM7'); + assert.equal(encodingByDataCoding(0x18), 'UCS2'); + assert.equal(encodingByDataCoding(0x1A), 'UCS2'); +- assert.equal(encodingByDataCoding(0xF0), 'ASCII'); +- assert.equal(encodingByDataCoding(0xF1), 'ASCII'); ++ assert.equal(encodingByDataCoding(0xF0), 'GSM7'); ++ assert.equal(encodingByDataCoding(0xF1), 'GSM7'); + }); + + // The compressed and automatic-deletion groups put the alphabet where the plain one does. + test('reads them in the compressed and automatic-deletion groups too', () => { +- assert.equal(encodingByDataCoding(0x30), 'ASCII'); ++ assert.equal(encodingByDataCoding(0x30), 'GSM7'); + assert.equal(encodingByDataCoding(0x38), 'UCS2'); + assert.equal(encodingByDataCoding(0x54), 'LATIN1'); + assert.equal(encodingByDataCoding(0x58), 'UCS2'); +@@ -197,9 +197,9 @@ describe('encodingByDataCoding()', () => { + }); + + test('falls back to ASCII for alphabets it has no codec for', () => { +- assert.equal(encodingByDataCoding(0x05), 'ASCII'); +- assert.equal(encodingByDataCoding(0x0E), 'ASCII'); ++ assert.equal(encodingByDataCoding(0x05), 'GSM7'); ++ assert.equal(encodingByDataCoding(0x0E), 'GSM7'); + // No class, so nothing says the octet is spelled 03.38 rather than SMPP's own flat table. +- assert.equal(encodingByDataCoding(0x48), 'ASCII'); ++ assert.equal(encodingByDataCoding(0x48), 'GSM7'); + }); + }); +diff --git a/test/interop.test.ts b/test/interop.test.ts +index 2e5d905..df93fee 100644 +--- a/test/interop.test.ts ++++ b/test/interop.test.ts +@@ -2,19 +2,29 @@ import assert from 'node:assert/strict'; + import test, { describe } from 'node:test'; + import reference from 'smpp'; + import type { ReferenceSession } from 'smpp'; +-import type { Sms } from '../src/sms.ts'; ++import type { Sms } from '../src/messages/sms.ts'; + import { client } from '../src/client.ts'; + import { closeAfter } from './teardown.ts'; +-import { concatInfo } from '../src/udh.ts'; +-import { objToPdu, pduToObj } from '../src/pdu.ts'; ++import { concatInfo } from '../src/messages/udh.ts'; ++import { objToPdu, pduToObj } from '../src/wire/pdu.ts'; + import { server } from '../src/server.ts'; +-import { splitMessage } from '../src/message.ts'; ++import { splitMessage } from '../src/messages/message.ts'; + + /** + * Cross-checks against farhadi/node-smpp, an independent SMPP implementation. This is what backs + * the claim that the corrected framing is right rather than differently wrong. + */ + ++type Deferred = { promise: Promise; resolve: (value: T) => void }; ++ ++/** A promise settled from the outside, for a handler that has to exist before what fires it. */ ++function deferred(): Deferred { ++ let resolve: (value: T) => void = () => undefined; ++ const promise = new Promise(settle => { resolve = settle; }); ++ ++ return { promise, resolve }; ++} ++ + function ourBuffer(...args: Parameters): Buffer { + const { buffer, err } = objToPdu(...args); + +@@ -249,16 +259,13 @@ describe('a live session against the reference implementation', () => { + }); + + test('a reference client binds to our server and delivers an SMS', async t => { +- const { err: serverErr, server: smpp } = await server({ port: 0 }); ++ const incoming = deferred(); ++ const { err: serverErr, server: smpp } = await server({ onSms: incoming.resolve, port: 0 }); + + assert.equal(serverErr, undefined); + assert.ok(smpp); + closeAfter(t, smpp); + +- const incoming = new Promise(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); +- + const refSession = reference.connect({ + url: `smpp://localhost:${String(smpp.port)}`, + }); +@@ -275,10 +282,9 @@ describe('a live session against the reference implementation', () => { + source_addr: '46701113311', + }); + +- const sms = await incoming; ++ const sms = await incoming.promise; + + assert.equal(sms.from, '46701113311'); + assert.equal(sms.message, 'from the reference client'); +- await sms.sendResp(); + }); + }); +diff --git a/test/message-class.test.ts b/test/message-class.test.ts +index e613139..82a29fd 100644 +--- a/test/message-class.test.ts ++++ b/test/message-class.test.ts +@@ -1,19 +1,19 @@ + import assert from 'node:assert/strict'; + import test, { describe } from 'node:test'; +-import type { PduObjectInput } from '../src/pdu.ts'; +-import type { SendSmsDeps } from '../src/send-sms.ts'; ++import type { PduObjectInput } from '../src/wire/pdu.ts'; ++import type { SendSmsDeps } from '../src/messages/send-sms.ts'; + import type { Session } from '../src/session.ts'; +-import type { Sms } from '../src/sms.ts'; ++import type { Sms } from '../src/messages/sms.ts'; + import type { TestContext } from 'node:test'; + import { bindToSmsc, dummySmsc } from './dummy-smsc.ts'; + import { client } from '../src/client.ts'; + import { closeAfter } from './teardown.ts'; + import { messageClassOf } from '../src/defs/encodings.ts'; + import { paramNumber } from '../src/defs/types.ts'; +-import { pduToObj } from '../src/pdu.ts'; ++import { pduToObj } from '../src/wire/pdu.ts'; + import { server } from '../src/server.ts'; + import { silentLog } from '../src/log.ts'; +-import { submitSms } from '../src/send-sms.ts'; ++import { submitSms } from '../src/messages/send-sms.ts'; + + const from = '46701113311'; + const to = '46709771337'; +@@ -27,18 +27,15 @@ type MessagePeer = { + /** A server that answers every message, and a client to write raw submit_sm PDUs at it. */ + async function messagesInto(t: TestContext): Promise { + const received: Sms[] = []; +- const { err, server: smpp } = await server({ port: 0 }); ++ const { err, server: smpp } = await server({ ++ onSms: sms => { received.push(sms); }, ++ port: 0, ++ }); + + assert.equal(err, undefined); + assert.ok(smpp); + closeAfter(t, smpp); + +- smpp.on('session', peer => peer.on('sms', sms => { +- received.push(sms); +- +- return sms.sendResp(); +- })); +- + const connected = await client({ port: smpp.port, reconnect: false }); + + assert.equal(connected.err, undefined); +@@ -216,7 +213,7 @@ describe('sendSms() flash', () => { + const sent = await submitSms(deps, { encoding, from, message: 'Hello world', to }); + + assert.ok(sent.err instanceof Error, JSON.stringify(encoding)); +- assert.match(sent.err.message, /encoding must be ASCII, LATIN1, UCS2/); ++ assert.match(sent.err.message, /encoding must be GSM7, LATIN1, UCS2/); + assert.deepEqual(sent.smsIds, []); + } + +diff --git a/test/message.test.ts b/test/message.test.ts +index 37392a4..9159ad9 100644 +--- a/test/message.test.ts ++++ b/test/message.test.ts +@@ -8,7 +8,7 @@ import { + smppDate, + smppTime, + splitMessage, +-} from '../src/message.ts'; ++} from '../src/messages/message.ts'; + // Through the public surface: an application handed a PduObject needs this same answer. + import { encodings, isEncodingName, messageOctets, objToPdu, pduToObj } from '../src/index.ts'; + +@@ -124,7 +124,7 @@ describe('splitMessage()', () => { + + describe('encodeMessage() and decodeMessage()', () => { + test('picks GSM for GSM-safe text and UCS2 otherwise', () => { +- assert.equal(encodeMessage('Hello').encoding, 'ASCII'); ++ assert.equal(encodeMessage('Hello').encoding, 'GSM7'); + assert.equal(encodeMessage('تست').encoding, 'UCS2'); + }); + +@@ -165,7 +165,7 @@ describe('encodeMessage() and decodeMessage()', () => { + }); + + describe('the alphabet the encoding helpers are asked for', () => { +- const everyName: EncodingName[] = ['ASCII', 'LATIN1', 'UCS2']; ++ const everyName: EncodingName[] = ['GSM7', 'LATIN1', 'UCS2']; + + test('is one of three, each with a codec, so none of the three helpers can reach an absent one', () => { + assert.deepEqual(Object.keys(encodings).sort(), [...everyName].sort()); +diff --git a/test/messaging-mode.test.ts b/test/messaging-mode.test.ts +index 4ac57ee..44e4efb 100644 +--- a/test/messaging-mode.test.ts ++++ b/test/messaging-mode.test.ts +@@ -1,16 +1,16 @@ + import assert from 'node:assert/strict'; + import test, { describe } from 'node:test'; +-import type { PduObjectInput } from '../src/pdu.ts'; +-import type { SendSmsDeps } from '../src/send-sms.ts'; ++import type { PduObjectInput } from '../src/wire/pdu.ts'; ++import type { SendSmsDeps } from '../src/messages/send-sms.ts'; + import type { Session } from '../src/session.ts'; + import type { SubmitMessagingMode } from '../src/defs/constants.ts'; + import type { TestContext } from 'node:test'; + import { bindToSmsc, dummySmsc } from './dummy-smsc.ts'; + import { consts, submitMessagingModes } from '../src/defs/constants.ts'; + import { paramNumber } from '../src/defs/types.ts'; +-import { pduToObj } from '../src/pdu.ts'; ++import { pduToObj } from '../src/wire/pdu.ts'; + import { silentLog } from '../src/log.ts'; +-import { submitSms } from '../src/send-sms.ts'; ++import { submitSms } from '../src/messages/send-sms.ts'; + + const from = '46701113311'; + const to = '46709771337'; +diff --git a/test/operator-receipts.test.ts b/test/operator-receipts.test.ts +index 36dd655..2fa7d5d 100644 +--- a/test/operator-receipts.test.ts ++++ b/test/operator-receipts.test.ts +@@ -1,12 +1,12 @@ + import assert from 'node:assert/strict'; + import test, { describe } from 'node:test'; +-import type { Dlr, Receipt } from '../src/dlr.ts'; ++import type { Dlr, Receipt } from '../src/messages/dlr.ts'; + import type { MessageDlr } from '../src/session.ts'; +-import type { PduObject, TlvInputs } from '../src/pdu.ts'; ++import type { PduObject, TlvInputs } from '../src/wire/pdu.ts'; + import { bindToSmsc, dummySmsc } from './dummy-smsc.ts'; + import { consts } from '../src/defs/constants.ts'; +-import { dlrFromPdu, parseReceipt, receiptCodes, transientStates } from '../src/dlr.ts'; +-import { objToPdu, pduToObj } from '../src/pdu.ts'; ++import { dlrFromPdu, parseReceipt, receiptCodes, transientStates } from '../src/messages/dlr.ts'; ++import { objToPdu, pduToObj } from '../src/wire/pdu.ts'; + + /** + * Receipt bodies as commercial operators document them, from `interop-tests/research/operator-quirks.md` +diff --git a/test/pdu-framer.test.ts b/test/pdu-framer.test.ts +index 1461b43..c7803d2 100644 +--- a/test/pdu-framer.test.ts ++++ b/test/pdu-framer.test.ts +@@ -1,7 +1,7 @@ + import assert from 'node:assert/strict'; + import test, { describe } from 'node:test'; +-import { PduFramer } from '../src/pdu-framer.ts'; +-import { objToPdu } from '../src/pdu.ts'; ++import { PduFramer } from '../src/wire/pdu-framer.ts'; ++import { objToPdu } from '../src/wire/pdu.ts'; + + function pdu(seqNr: number): Buffer { + const { buffer } = objToPdu({ cmdName: 'enquire_link', seqNr }); +diff --git a/test/pdu.test.ts b/test/pdu.test.ts +index c773865..6322734 100644 +--- a/test/pdu.test.ts ++++ b/test/pdu.test.ts +@@ -1,7 +1,7 @@ + import assert from 'node:assert/strict'; + import test, { describe } from 'node:test'; +-import { PduRefusedError, refusalAnswer } from '../src/pdu-refusal.ts'; +-import { isCommand, isResp, objToPdu, pduReturn, pduToObj } from '../src/pdu.ts'; ++import { PduRefusedError, refusalAnswer } from '../src/wire/pdu-refusal.ts'; ++import { isCommand, isResp, objToPdu, pduReturn, pduToObj } from '../src/wire/pdu.ts'; + import { paramText } from '../src/defs/types.ts'; + import { isTlvName, tlvsById } from '../src/defs/tlvs.ts'; + +diff --git a/test/raw-pdus.ts b/test/raw-pdus.ts +index 46ae6f4..0173069 100644 +--- a/test/raw-pdus.ts ++++ b/test/raw-pdus.ts +@@ -1,6 +1,6 @@ + import assert from 'node:assert/strict'; +-import type { PduObjectInput } from '../src/pdu.ts'; +-import { objToPdu } from '../src/pdu.ts'; ++import type { PduObjectInput } from '../src/wire/pdu.ts'; ++import { objToPdu } from '../src/wire/pdu.ts'; + + /** The octets a test writes straight to a socket, which objToPdu builds for every valid PDU. */ + export function pduBytes(input: PduObjectInput): Buffer { +diff --git a/test/readme.test.ts b/test/readme.test.ts +index f2198a5..0faf476 100644 +--- a/test/readme.test.ts ++++ b/test/readme.test.ts +@@ -1,15 +1,16 @@ + import assert from 'node:assert/strict'; + import test, { describe } from 'node:test'; +-import type { Dlr } from '../src/dlr.ts'; ++import type { Dlr } from '../src/messages/dlr.ts'; + import type { Session } from '../src/session.ts'; +-import type { Sms } from '../src/sms.ts'; ++import type { Sms } from '../src/messages/sms.ts'; + import type { SmppLog } from '../src/log.ts'; + import type { SmppServer } from '../src/server.ts'; ++import type { SmsHandler } from '../src/session/session-options.ts'; + import type { TestContext } from 'node:test'; +-import { PduRefusedError } from '../src/pdu-refusal.ts'; ++import { PduRefusedError } from '../src/wire/pdu-refusal.ts'; + import { client } from '../src/client.ts'; + import { closeAfter } from './teardown.ts'; +-import { isCommand, objToPdu } from '../src/pdu.ts'; ++import { isCommand, objToPdu } from '../src/wire/pdu.ts'; + import { server } from '../src/server.ts'; + + function once(register: (resolve: (value: T) => void) => void): Promise { +@@ -25,22 +26,30 @@ function once(register: (resolve: (value: T) => void) => void): Promise { + }); + } + +-/** The README's examples listen on the documented default port, so one server runs at a time. */ +-async function answeringServer(t: TestContext): Promise { +- const { err, server: smpp } = await server(); ++type Deferred = { promise: Promise; resolve: (value: T) => void }; + +- assert.equal(err, undefined); +- assert.ok(smpp); +- closeAfter(t, smpp); ++/** A promise settled from the outside, for a handler that has to exist before what fires it. */ ++function deferred(): Deferred { ++ let resolve: (value: T) => void = () => undefined; ++ const promise = once(settle => { resolve = settle; }); ++ ++ return { promise, resolve }; ++} + +- smpp.on('session', session => { +- session.on('sms', async sms => { +- await sms.sendResp(); ++/** The README's examples listen on the documented default port, so one server runs at a time. */ ++async function answeringServer(t: TestContext, onSms?: SmsHandler): Promise { ++ const { err, server: smpp } = await server({ ++ onSms: async sms => { ++ onSms?.(sms); + + if (sms.dlr) await sms.sendDlr(); +- }); ++ }, + }); + ++ assert.equal(err, undefined); ++ assert.ok(smpp); ++ closeAfter(t, smpp); ++ + return smpp; + } + +@@ -121,10 +130,10 @@ describe('README: Client', () => { + }); + + test('the documented sending options', async t => { +- const smpp = await answeringServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); ++ const incoming = deferred(); ++ ++ await answeringServer(t, incoming.resolve); ++ + const { err, session } = await client(); + if (err) throw err; + +@@ -132,7 +141,7 @@ describe('README: Client', () => { + + const { signal } = new AbortController(); + const [sms, sent] = await Promise.all([ +- incoming, ++ incoming.promise, + session.sendSms({ + dlr: true, + encoding: 'UCS2', +@@ -154,15 +163,20 @@ describe('README: Client', () => { + test('receiving an inbound message on a client session', async t => { + const smpp = await answeringServer(t); + const bound = once(resolve => { smpp.on('session', resolve); }); +- const { err, session } = await client(); ++ const incoming = deferred(); ++ const { err, session } = await client({ ++ onSms: sms => { ++ // sms.from, sms.to, sms.message, sms.smsId ++ incoming.resolve(sms); ++ }, ++ }); + if (err) throw err; + + closeAfter(t, session); + +- const incoming = once(resolve => { session.on('sms', resolve); }); + const peer = await bound; + +- void peer.send({ ++ const delivered = peer.send({ + cmdName: 'deliver_sm', + params: { + destination_addr: '46709771337', +@@ -171,29 +185,25 @@ describe('README: Client', () => { + }, + }); + +- const sms = await incoming; +- +- await sms.sendResp(); ++ const sms = await incoming.promise; + + assert.equal(sms.message, 'inbound hello'); ++ assert.equal((await delivered).pduObj?.cmdStatus, 'ESME_ROK', 'answered before the handler ran'); + }); + }); + + describe('README: Server', () => { + test('the simplest possible server', async t => { +- const { err, server: smpp } = await server(); +- if (err) throw err; +- +- closeAfter(t, smpp); +- + const received: string[] = []; +- +- smpp.on('session', session => { +- session.on('sms', async sms => { ++ const { err, server: smpp } = await server({ ++ onSms: sms => { ++ // sms.from, sms.to, sms.message, sms.dlr, sms.smsId, sms.session + received.push(sms.message); +- await sms.sendResp(); +- }); ++ }, + }); ++ if (err) throw err; ++ ++ closeAfter(t, smpp); + + const { err: clientErr, session } = await client(); + +@@ -208,34 +218,26 @@ describe('README: Server', () => { + }); + + test('with authentication and delivery reports', async t => { ++ let userData: unknown; + const { err, server: smpp } = await server({ ++ // Replace with your own auth. Returning an object attaches it to session.userData. + authenticate: ({ password, systemId }) => { + if (systemId !== 'foo' || password !== 'bar') return false; + + return { userData: { userId: 123 } }; + }, +- }); +- if (err) throw err; +- +- closeAfter(t, smpp); +- +- let answeredOnArrival: boolean | undefined; +- +- smpp.on('session', session => { +- session.on('sms', async sms => { +- answeredOnArrival = sms.answeredOnArrival; +- +- if (sms.answeredOnArrival) { +- await sms.sendResp(); // multipart: only releases the shutdown drain +- } else { +- await sms.sendResp(); // ESME_ROK with a generated id +- } ++ onSms: async sms => { ++ // Already answered ESME_ROK under sms.smsId; sms.session.userData is what authenticate returned. ++ userData = sms.session.userData; + + if (sms.dlr) { +- await sms.sendDlr(); ++ await sms.sendDlr(); // same as sms.sendDlr('DELIVERED') + } +- }); ++ }, + }); ++ if (err) throw err; ++ ++ closeAfter(t, smpp); + + assert.equal(smpp.port, 2775); + +@@ -259,11 +261,12 @@ describe('README: Server', () => { + + assert.equal(sent.err, undefined); + assert.equal((await reported).statusMsg, 'DELIVERED'); +- assert.equal(answeredOnArrival, false); ++ assert.deepEqual(userData, { userId: 123 }); + }); + + test('refusing a segment at onRequest, before this library would answer it', async t => { + const knownRecipients = new Set(['46709771337']); ++ let messages = 0; + const { err, server: smpp } = await server({ + onRequest: async (session, pduObj) => { + if (!isCommand(pduObj, 'submit_sm') || knownRecipients.has(pduObj.params.destination_addr)) { +@@ -274,15 +277,12 @@ describe('README: Server', () => { + + return true; + }, ++ onSms: () => { messages++; }, + }); + if (err) throw err; + + closeAfter(t, smpp); + +- let messages = 0; +- +- smpp.on('session', bound => bound.on('sms', () => { messages++; })); +- + const connected = await client(); + if (connected.err) throw connected.err; + +diff --git a/test/session-error.test.ts b/test/session-error.test.ts +index af9b3b9..5c1157e 100644 +--- a/test/session-error.test.ts ++++ b/test/session-error.test.ts +@@ -38,8 +38,12 @@ async function waitFor(condition: () => boolean, budget = 2000): Promise[0] = {}) { +- const { err, server: smpp } = await server({ port: 0 }); ++async function linked( ++ t: TestContext, ++ options: Parameters[0] = {}, ++ serverOptions: Parameters[0] = {}, ++) { ++ const { err, server: smpp } = await server({ ...serverOptions, port: 0 }); + + assert.equal(err, undefined); + assert.ok(smpp); +@@ -95,11 +99,11 @@ describe('telling a refused PDU from a failed session', () => { + }); + + test('reports a listener that threw as an error that is no refusal', async t => { +- const { peer, session } = await linked(t, { responseTimeout: 200 }); ++ const { peer, session } = await linked(t, { responseTimeout: 200 }, { ++ onSms: () => { throw new Error('listener exploded'); }, ++ }); + const failed = once(resolve => { peer.on('sessionError', resolve); }); + +- peer.on('sms', () => { throw new Error('listener exploded'); }); +- + await session.sendSms({ from: '46701113311', message: 'blows the listener up', to: '46709771337' }); + + const reported = await raceWithin(2000, failed); +diff --git a/test/session-extras.test.ts b/test/session-extras.test.ts +index 6b36279..a47c6d6 100644 +--- a/test/session-extras.test.ts ++++ b/test/session-extras.test.ts +@@ -1,42 +1,46 @@ + import assert from 'node:assert/strict'; + import net from 'node:net'; + import test, { describe } from 'node:test'; +-import type { Collected, LostGroup } from '../src/reassembly.ts'; +-import type { Dlr } from '../src/dlr.ts'; ++import type { Collected, LostGroup } from '../src/messages/reassembly.ts'; ++import type { Dlr } from '../src/messages/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 { IncomingRequestsOptions } from '../src/session/incoming-requests.ts'; ++import type { RunningHandlersOptions } from '../src/session/running-handlers.ts'; + import type { MessageState } from '../src/defs/constants.ts'; ++import type { OnRequest } from '../src/session/session-options.ts'; + import type { MessageDlr } from '../src/session.ts'; +-import type { PduObject, PduObjectInput } from '../src/pdu.ts'; +-import type { Result } from '../src/result.ts'; +-import type { SendSmsResult } from '../src/send-sms.ts'; ++import type { PduObject, PduObjectInput } from '../src/wire/pdu.ts'; ++import type { Result, VoidResult } from '../src/result.ts'; ++import type { SendSmsResult } from '../src/messages/send-sms.ts'; + import type { SmppLog } from '../src/log.ts'; +-import type { Sms } from '../src/sms.ts'; ++import type { Sms } from '../src/messages/sms.ts'; + import type { SmppServer } from '../src/server.ts'; + import type { TestContext } from 'node:test'; +-import { HeldMessages } from '../src/held-messages.ts'; +-import { IncomingRequests, refusedSegmentStatus } from '../src/incoming-requests.ts'; +-import { UnansweredError } from '../src/unanswered-error.ts'; +-import { createSms } from '../src/sms.ts'; +-import { LinkLife } from '../src/link-life.ts'; +-import { SendWindow } from '../src/send-window.ts'; +-import { Reassembler, decodeSegments } from '../src/reassembly.ts'; ++import { RunningHandlers } from '../src/session/running-handlers.ts'; ++import { IncomingRequests, refusedSegmentStatus } from '../src/session/incoming-requests.ts'; ++import { UnansweredError } from '../src/messages/unanswered-error.ts'; ++import { createSms } from '../src/messages/sms.ts'; ++import { LinkLife } from '../src/session/link-life.ts'; ++import { SendWindow } from '../src/session/send-window.ts'; ++import { Reassembler, decodeSegments } from '../src/messages/reassembly.ts'; + 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 { DlrMerger } from '../src/messages/dlr-merger.ts'; ++import { PduRefusedError } from '../src/wire/pdu-refusal.ts'; ++import { isCommand, objToPdu } from '../src/wire/pdu.ts'; ++import { bounds } from '../src/defaults.ts'; ++import { checkSessionOptions } from '../src/session/session-options.ts'; ++import { standsInFor } from '../src/session/bind-direction.ts'; + import { client } from '../src/client.ts'; + import { closeAfter, closeListenerAfter } from './teardown.ts'; +-import { concatOf } from '../src/concat.ts'; ++import { concatOf } from '../src/messages/concat.ts'; + import { consts } from '../src/defs/constants.ts'; + import { errors } from '../src/defs/errors.ts'; + import { paramNumber, paramText } from '../src/defs/types.ts'; + import { server } from '../src/server.ts'; + import { silentLog } from '../src/log.ts'; +-import { splitMessage } from '../src/message.ts'; +-import { submitSms, submitSmParams } from '../src/send-sms.ts'; ++import { splitMessage } from '../src/messages/message.ts'; ++import { submitSms, submitSmParams } from '../src/messages/send-sms.ts'; ++import { uuidv7 } from '../src/messages/uuid.ts'; + + async function startServer( + t: TestContext, +@@ -81,6 +85,19 @@ function delay(ms: number): Promise { + return new Promise(resolve => { setTimeout(resolve, ms); }); + } + ++type Deferred = { promise: Promise; resolve: (value: T) => void }; ++ ++/** A promise settled from the outside, for a handler that has to exist before what fires it. */ ++function deferred(): Deferred { ++ let resolve: (value: T) => void = () => undefined; ++ const promise = once(settle => { resolve = settle; }); ++ ++ return { promise, resolve }; ++} ++ ++/** A hook that keeps every submit_sm unanswered, which is how a test holds the peer's window open. */ ++const holdSubmits: OnRequest = (_session, pduObj) => isCommand(pduObj, 'submit_sm'); ++ + /** Undefined where the promise never settled, which is an assertion rather than a hung run. */ + function within(ms: number, promise: Promise): Promise { + return Promise.race([promise, delay(ms).then((): undefined => undefined)]); +@@ -104,9 +121,8 @@ function abortAfter( + function incomingOn(session: Session, options: Partial = {}): IncomingRequests { + 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') }), ++ sendReceipt: () => Promise.resolve({ err: new Error('never sent') }), + session, + ...options, + }); +@@ -170,10 +186,8 @@ function latch(): Latch { + describe('merged delivery reports', () => { + // 0.4.0 allocated a longSmsDlrs store to do exactly this and then never used it. + test('reports once on a whole multipart message', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); ++ const incoming = deferred(); ++ const smpp = await startServer(t, { onSms: incoming.resolve }); + const { session } = await connect(t, smpp); + + assert.ok(session); +@@ -184,11 +198,7 @@ describe('merged delivery reports', () => { + session.on('dlr', dlr => perSegment.push(dlr.smsId ?? '')); + + const [sms] = await Promise.all([ +- incoming.then(async received => { +- await received.sendResp(); +- +- return received; +- }), ++ incoming.promise, + session.sendSms({ + dlr: true, + from: '46701113311', +@@ -208,10 +218,8 @@ describe('merged delivery reports', () => { + }); + + test('reports once, on the final receipts, when the peer reports en route first', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); ++ const incoming = deferred(); ++ const smpp = await startServer(t, { onSms: incoming.resolve }); + const { session } = await connect(t, smpp); + + assert.ok(session); +@@ -226,11 +234,7 @@ describe('merged delivery reports', () => { + }); + + const [sms] = await Promise.all([ +- incoming.then(async received => { +- await received.sendResp(); +- +- return received; +- }), ++ incoming.promise, + session.sendSms({ + dlr: true, + from: '46701113311', +@@ -264,10 +268,8 @@ describe('merged delivery reports', () => { + }); + + test('reports the worst status across the segments', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); ++ const incoming = deferred(); ++ const smpp = await startServer(t, { onSms: incoming.resolve }); + const { session } = await connect(t, smpp); + + assert.ok(session); +@@ -275,11 +277,7 @@ describe('merged delivery reports', () => { + const merged = once(resolve => { session.on('messageDlr', resolve); }); + + const [sms] = await Promise.all([ +- incoming.then(async received => { +- await received.sendResp(); +- +- return received; +- }), ++ incoming.promise, + session.sendSms({ + dlr: true, + from: '46701113311', +@@ -378,18 +376,20 @@ describe('sendSms()', () => { + } + + test('reports a submit_sm the peer refused instead of an empty message id', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); ++ const smpp = await startServer(t, { ++ onRequest: async (peer, pduObj) => { ++ if (!isCommand(pduObj, 'submit_sm')) return false; ++ ++ await peer.sendReturn(pduObj, 'ESME_RMSGQFUL'); ++ ++ return true; ++ }, + }); + const { session } = await connect(t, smpp); + + assert.ok(session); + +- const [, sent] = await Promise.all([ +- incoming.then(received => received.sendResp({ status: 'ESME_RMSGQFUL' })), +- session.sendSms({ from: '46701113311', message: 'the queue is full', to: '46709771337' }), +- ]); ++ const sent = await session.sendSms({ from: '46701113311', message: 'the queue is full', to: '46709771337' }); + + assert.ok(sent.err instanceof Error); + assert.match(sent.err.message, /ESME_RMSGQFUL/); +@@ -602,17 +602,8 @@ describe('reconnect', () => { + }); + + test('re-binds after the connection drops, keeping the same session object', async t => { +- const smpp = await startServer(t); + const messages: string[] = []; +- +- // Registered up front so the session created by the reconnect is covered too. +- smpp.on('session', bound => { +- bound.on('sms', sms => { +- messages.push(sms.message); +- void sms.sendResp(); +- }); +- }); +- ++ const smpp = await startServer(t, { onSms: sms => { messages.push(sms.message); } }); + const { err, session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } }); + + assert.equal(err, undefined); +@@ -652,13 +643,8 @@ describe('reconnect', () => { + }); + + test('merges the receipts of a multipart message across a drop', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', bound => bound.on('sms', sms => { +- resolve(sms); +- void sms.sendResp(); +- })); +- }); ++ const incoming = deferred(); ++ const smpp = await startServer(t, { onSms: incoming.resolve }); + const { session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } }); + + assert.ok(session); +@@ -669,7 +655,7 @@ describe('reconnect', () => { + message: 'x'.repeat(400), + to: '46709771337', + }); +- const sms = await incoming; ++ const sms = await incoming.promise; + + assert.deepEqual(sent.smsIds, [1, 2, 3].map(part => `${sms.smsId}-${String(part)}`)); + +@@ -692,41 +678,28 @@ describe('reconnect', () => { + assert.equal(report.segments.length, 3); + }); + +- test('refuses to answer a message whose link went, held or already answered', async t => { ++ test('sends the receipt for a message whose link went on the new link', async t => { + const smpp = await startServer(t); +- const { session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } }); ++ const arrived = deferred(); ++ const { session } = await connect(t, smpp, { ++ onSms: arrived.resolve, ++ reconnect: { maxDelay: 100, minDelay: 20 }, ++ }); + + assert.ok(session); + +- const arrived: Sms[] = []; +- const both = once(resolve => { +- session.on('sms', sms => { +- arrived.push(sms); +- +- if (arrived.length === 2) resolve(true); +- }); ++ const delivered = await peerOf(smpp).send({ ++ cmdName: 'deliver_sm', ++ params: { ++ destination_addr: '46701113311', ++ short_message: 'answered before the drop', ++ source_addr: '46709771337', ++ }, + }); + +- for (const text of ['answered before the drop', 'never answered']) { +- void peerOf(smpp).send({ +- cmdName: 'deliver_sm', +- params: { +- destination_addr: '46701113311', +- short_message: text, +- source_addr: '46709771337', +- }, +- }); +- } +- +- await both; +- +- const [answered, held] = arrived; +- +- assert.ok(answered); +- assert.ok(held); +- assert.equal(answered.message, 'answered before the drop'); +- assert.equal((await answered.sendResp()).err, undefined); ++ assert.equal(delivered.err, undefined, 'answered on arrival, on the link it arrived on'); + ++ const sms = await arrived.promise; + const reconnected = once(resolve => { session.on('reconnected', () => { resolve(true); }); }); + + await peerOf(smpp).close({ signal: AbortSignal.abort() }); +@@ -734,14 +707,10 @@ describe('reconnect', () => { + + let taken = 0; + +- // A response is dispatched before `incomingPduObj`, so only the raw event sees one arrive. + peerOf(smpp).on('incomingPdu', () => { taken++; }); + +- assert.match((await answered.sendResp()).err?.message ?? '', /link this message arrived on is gone/); +- assert.match((await held.sendResp()).err?.message ?? '', /link this message arrived on is gone/); +- +- assert.equal((await held.sendDlr('DELIVERED')).err, undefined); +- assert.equal(taken, 1, 'a refused response reached the new link'); ++ assert.equal((await sms.sendDlr('DELIVERED')).err, undefined); ++ assert.equal(taken, 1, 'the receipt reached the new link'); + }); + + test('drops a message whose link went while onRequest was still running', async t => { +@@ -749,23 +718,22 @@ describe('reconnect', () => { + + closeAfter(t, session); + +- const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 100 }); +- const incoming = incomingOn(session, { link, onRequest: async () => { await delay(10); return false; } }); + let messages = 0; ++ const incoming = incomingOn(session, { ++ onRequest: async () => { await delay(10); return false; }, ++ onSms: () => { messages++; }, ++ }); + +- session.on('sms', () => { messages++; }); ++ await incoming.handle(submitPdu(2)); + +- const handled = incoming.handle(submitPdu(1)); ++ assert.equal(messages, 1, 'the harness delivers a message whose link stayed'); + +- link.drop(); ++ const handled = incoming.handle(submitPdu(1)); + ++ session.sock.destroy(); + await handled; + +- assert.equal(messages, 0); +- +- await incoming.handle(submitPdu(2)); +- +- assert.equal(messages, 1, 'the harness delivers a message whose link stayed'); ++ assert.equal(messages, 1); + }); + + test('does not reconnect after an explicit close', async t => { +@@ -1147,28 +1115,47 @@ describe('connectTimeout', () => { + }); + + describe('sends across a reconnect', () => { +- /** Answers every message after the first, which is left to hold the send window open. */ +- function answerAfterTheFirst(smpp: SmppServer, arrived: string[]): Latch { ++ /** Holds the first submit unanswered, so it keeps the send window open; every later one is taken. */ ++ function holdTheFirst(): { arrived: string[]; first: Latch; options: Parameters[0] } { ++ const arrived: string[] = []; + const first = latch(); ++ let seen = 0; + +- smpp.on('session', peer => { +- peer.on('sms', async sms => { +- arrived.push(sms.message); ++ return { ++ arrived, ++ first, ++ options: { ++ onRequest: (_peer, pduObj) => { ++ if (!isCommand(pduObj, 'submit_sm') || seen++ > 0) return false; + +- if (arrived.length === 1) first.open(); +- else await sms.sendResp(); +- }); +- }); ++ first.open(); + +- return first; ++ return true; ++ }, ++ onSms: sms => { arrived.push(sms.message); }, ++ }, ++ }; + } + +- test('holds a send issued while the link is down and puts it on the new link', async t => { +- const smpp = await startServer(t); +- const arrived: string[] = []; ++ /** Resolves with the submit_sm as it lands, leaving it unanswered. */ ++ function arrivalOf(): { arrived: Promise; onRequest: OnRequest } { ++ const landed = deferred(); + +- smpp.on('session', peer => { peer.on('sms', async sms => { arrived.push(sms.message); await sms.sendResp(); }); }); ++ return { ++ arrived: landed.promise, ++ onRequest: (_peer, pduObj) => { ++ if (!isCommand(pduObj, 'submit_sm')) return false; + ++ landed.resolve(pduObj); ++ ++ return true; ++ }, ++ }; ++ } ++ ++ test('holds a send issued while the link is down and puts it on the new link', async t => { ++ const arrived: string[] = []; ++ const smpp = await startServer(t, { onSms: sms => { arrived.push(sms.message); } }); + const { session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } }); + + assert.ok(session); +@@ -1187,9 +1174,8 @@ describe('sends across a reconnect', () => { + }); + + test('puts a segment still queued behind a full window on the new link', async t => { +- const smpp = await startServer(t); +- const arrived: string[] = []; +- const first = answerAfterTheFirst(smpp, arrived); ++ const { arrived, first, options } = holdTheFirst(); ++ const smpp = await startServer(t, options); + const { session } = await connect(t, smpp, { + maxOutstanding: 1, + reconnect: { maxDelay: 100, minDelay: 20 }, +@@ -1210,7 +1196,7 @@ describe('sends across a reconnect', () => { + assert.equal(dropped.unanswered, 1); + assert.equal(resent.err, undefined, 'a request that never reached the socket is not lost with it'); + assert.equal(resent.smsIds.length, 1); +- assert.deepEqual(arrived, ['first', 'second']); ++ assert.deepEqual(arrived, ['second'], 'the held one reached the hook alone'); + }); + + test('holds a send issued while the rebind is still binding', async t => { +@@ -1228,10 +1214,8 @@ describe('sends across a reconnect', () => { + + return true; + }, ++ onSms: () => undefined, + }); +- +- smpp.on('session', peer => { peer.on('sms', async sms => { await sms.sendResp(); }); }); +- + const { session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } }); + + assert.ok(session); +@@ -1258,10 +1242,7 @@ describe('sends across a reconnect', () => { + }); + + test('counts a segment the peer never answered in time as unanswered', async t => { +- const smpp = await startServer(t); +- +- smpp.on('session', peer => { peer.on('sms', () => undefined); }); +- ++ const smpp = await startServer(t, { onRequest: holdSubmits }); + const { session } = await connect(t, smpp, { responseTimeout: 200 }); + + assert.ok(session); +@@ -1273,8 +1254,8 @@ describe('sends across a reconnect', () => { + }); + + test('counts a segment aborted after it went out as unanswered', async t => { +- const smpp = await startServer(t); +- const arrived = once(resolve => { smpp.on('session', peer => peer.on('sms', resolve)); }); ++ const { arrived, onRequest } = arrivalOf(); ++ const smpp = await startServer(t, { onRequest }); + const { session } = await connect(t, smpp); + + assert.ok(session); +@@ -1295,8 +1276,8 @@ describe('sends across a reconnect', () => { + }); + + test('reports a segment the link dropped under as unanswered, not as never sent', async t => { +- const smpp = await startServer(t); +- const arrived = once(resolve => { smpp.on('session', peer => peer.on('sms', resolve)); }); ++ const { arrived, onRequest } = arrivalOf(); ++ const smpp = await startServer(t, { onRequest }); + const { session } = await connect(t, smpp, { reconnect: false }); + + assert.ok(session); +@@ -1404,10 +1385,10 @@ describe('sends across a reconnect', () => { + }); + + describe('LinkLife', () => { +- test('refuses a hold whose deadline has already passed', async () => { ++ test('refuses a budget already spent when the link comes back', 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(); + now = 101; +@@ -1424,10 +1405,11 @@ describe('LinkLife', () => { + link.drop(); + + 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.attach(); + link.open(); + + assert.deepEqual(await held, {}); +@@ -1439,19 +1421,24 @@ describe('LinkLife', () => { + + link.drop(); + +- 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/); + }); + +- test('awaits the next link only while down with one on its way', () => { ++ test('carries requests from the bind on, and awaits the next link only while one is on its way', () => { + const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 100 }); + ++ assert.equal(link.isUp(), false, 'attached, not yet bound'); ++ assert.equal(link.awaitsNextLink(), true, 'attached, not yet bound'); ++ link.open(); ++ assert.equal(link.isUp(), true, 'bound'); + assert.equal(link.awaitsNextLink(), false, 'up'); + link.drop(); + assert.equal(link.awaitsNextLink(), true, 'down, returning'); ++ link.open(); ++ assert.equal(link.isUp(), false, 'a bind on no socket opens nothing'); + link.attach(); +- assert.equal(link.awaitsNextLink(), true, 'attached, not yet bound'); + link.open(); + assert.equal(link.awaitsNextLink(), false, 'reopened'); + link.drop(); +@@ -1462,18 +1449,17 @@ describe('LinkLife', () => { + 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', () => { + 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.equal(link.generation(), generation + 1); ++ assert.equal(link.retrying(), true); ++ assert.equal(link.drop(), true); ++ assert.equal(link.drop(), false, 'already down'); + link.attach(); + link.stop(); +- assert.equal(link.drop(), 'close', '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.retrying(), false, 'a new link drops again, with none to follow it'); ++ assert.equal(link.drop(), true); ++ assert.equal(new LinkLife({ log: silentLog, reconnects: false, timeout: 100 }).retrying(), false); + }); + + test('releases a held request with the reason once the link ends', async () => { +@@ -1481,7 +1467,7 @@ describe('LinkLife', () => { + + link.drop(); + +- const held = link.hold(undefined)(); ++ const held = link.budget(undefined)(); + + link.end(); + +@@ -1497,9 +1483,10 @@ describe('SendWindow', () => { + const window = new SendWindow({ limit: 1, log: silentLog }); + const controller = new AbortController(); + +- assert.deepEqual(await window.acquire(undefined), {}); ++ assert.equal(window.take(), true); ++ assert.equal(window.take(), false, 'a full window hands out nothing on the spot'); + +- const queued = window.acquire(controller.signal); ++ const queued = window.wait(controller.signal); + + controller.abort(); + +@@ -1512,24 +1499,24 @@ describe('SendWindow', () => { + const window = new SendWindow({ limit: 1, log: silentLog }); + const controller = new AbortController(); + +- await window.acquire(undefined); ++ window.take(); + +- const abandoned = window.acquire(controller.signal); ++ const abandoned = window.wait(controller.signal); + + controller.abort(); + await abandoned; + window.release(); + + assert.equal(window.unfinished(), 0, 'the slot is free, not stranded on the waiter that left'); +- assert.deepEqual(await window.acquire(undefined), {}, 'so the next send takes it at once'); ++ assert.equal(window.take(), true, 'so the next send takes it at once'); + }); + + test('takes no slot for a signal that was already aborted', async () => { + const window = new SendWindow({ limit: 1, log: silentLog }); + +- await window.acquire(undefined); ++ window.take(); + +- const refused = await window.acquire(AbortSignal.abort()); ++ const refused = await window.wait(AbortSignal.abort()); + + assert.match(refused.err?.message ?? '', /Aborted while waiting for a send window slot/); + assert.equal(window.unfinished(), 1); +@@ -1537,203 +1524,204 @@ describe('SendWindow', () => { + }); + + // Goal 4: an application that answers nothing must not grow this for the life of the link. +-describe('held message bounds', () => { +- function message(seqNr: number): PduObject[] { +- return [submitPdu(seqNr)]; +- } ++describe('running handler bounds', () => { ++ function handlersOn(t: TestContext, options: Partial = {}): RunningHandlers { ++ const handlers = new RunningHandlers({ ++ log: silentLog, ++ max: 2, ++ onFailure: () => undefined, ++ timeout: 10_000, ++ ...options, ++ }); + +- function offer(held: HeldMessages, seqNr: number): MessageHold { +- const hold = held.offer(message(seqNr)); ++ t.after(() => { handlers.release(); }); + +- assert.ok(hold); ++ return handlers; ++ } + +- return hold; ++ function smsOn(session: Session): Sms { ++ return createSms({ pduObjs: [submitPdu(1)], session, smsId: uuidv7() }, () => Promise.resolve({ err: new Error('never sent') })); + } + +- /** Offers to a session with a listener, so an offer is held rather than released as untaken. */ +- function heldOn( +- t: TestContext, +- options: Pick, +- ): HeldMessages { +- const session = new Session({ sock: new net.Socket() }); ++ /** Runs a handler that finishes only when the latch opens. */ ++ function runHeld(handlers: RunningHandlers, session: Session): Latch { ++ const finish = latch(); + +- closeAfter(t, session); +- session.on('sms', () => undefined); ++ handlers.run(smsOn(session), () => finish.passed); + +- 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 finish; + } + +- test('is full at its count, and a re-used sequence number replaces rather than adding', t => { +- const held = heldOn(t, { max: 2, maxOctets: 1_000_000, timeout: 10_000 }); +- const first = offer(held, 1); +- const replaced = offer(held, 2); ++ test('is full at its count, until a handler finishes by any way out', async t => { ++ const session = new Session({ sock: new net.Socket() }); + +- offer(held, 2); ++ closeAfter(t, session); + +- 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); ++ const failures: string[] = []; ++ const handlers = handlersOn(t, { onFailure: err => { failures.push(err.message); } }); ++ const first = runHeld(handlers, session); + +- held.clear(); +- }); ++ assert.equal(handlers.full(), false); + +- // 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 => { +- let now = 0; +- const held = heldOn(t, { max: 10, maxOctets: 2000, now: () => now, timeout: 10_000 }); +- const answered = offer(held, 1); ++ const second = runHeld(handlers, session); ++ ++ assert.equal(handlers.size, 2); ++ assert.equal(handlers.full(), true); + +- assert.equal(held.full(), false); +- offer(held, 2); +- assert.equal(held.full(), true); ++ first.open(); ++ await delay(1); ++ assert.equal(handlers.full(), false, 'after one resolved'); + +- answered.release(); +- assert.equal(held.full(), false, 'after a release'); +- offer(held, 3); ++ handlers.run(smsOn(session), () => Promise.reject(new Error('gave up'))); ++ await delay(1); ++ assert.equal(handlers.full(), false, 'after one rejected'); ++ assert.deepEqual(failures, ['gave up']); + +- now = 20_000; +- held.sweep(); +- now = 0; +- assert.equal(held.full(), false, 'after a sweep'); +- offer(held, 4); +- offer(held, 5); ++ handlers.run(smsOn(session), () => { throw new Error('exploded'); }); ++ await delay(1); ++ assert.equal(handlers.full(), false, 'after one threw'); ++ assert.deepEqual(failures, ['gave up', 'exploded']); + +- held.clear(); +- assert.equal(held.full(), false, 'after a clear'); ++ second.open(); ++ await delay(1); ++ assert.equal(handlers.size, 0); + }); + +- // Dropping one the application still holds frees nothing, and the drain stops waiting for it. + test('refuses what arrives past the bound with a status that asks the peer to retry', async t => { + const session = new Session({ sock: new net.Socket() }); + + closeAfter(t, session); + + const warnings: string[] = []; +- const incoming = incomingOn(session, { log: { ...silentLog, warn: message => { warnings.push(message); } } }); ++ const finished: Latch[] = []; ++ const incoming = incomingOn(session, { ++ log: { ...silentLog, warn: message => { warnings.push(message); } }, ++ onSms: () => { ++ const finish = latch(); ++ ++ finished.push(finish); ++ ++ return finish.passed; ++ }, ++ }); + const answers: (ErrorName | undefined)[] = []; +- const received: Sms[] = []; + + session.sendReturn = (_pdu, status) => { + answers.push(status); + + return Promise.resolve({}); + }; +- session.on('sms', sms => { received.push(sms); }); + +- for (let seqNr = 1; seqNr <= defaults.maxHeldMessages; seqNr++) { ++ for (let seqNr = 1; seqNr <= bounds.maxRunningHandlers; seqNr++) { + await incoming.handle(submitPdu(seqNr)); + } + +- assert.equal(received.length, defaults.maxHeldMessages); ++ assert.equal(incoming.running, bounds.maxRunningHandlers); + +- await incoming.handle(submitPdu(defaults.maxHeldMessages + 1)); ++ await incoming.handle(submitPdu(bounds.maxRunningHandlers + 1)); + await incoming.handle(segment(7, 1, 2)); + +- assert.equal(received.length, defaults.maxHeldMessages); +- assert.deepEqual(answers, ['ESME_RTHROTTLED', 'ESME_RTHROTTLED']); ++ assert.equal(finished.length, bounds.maxRunningHandlers); ++ assert.deepEqual(answers.slice(-2), ['ESME_RTHROTTLED', 'ESME_RTHROTTLED']); + assert.equal(warnings.length, 1, 'reaching the bound warns once, not per refusal'); + + // The refused first segment joined no group, so the second one is taken and completes nothing. +- await received[0]?.sendResp(); +- await new Promise(resolve => { setImmediate(resolve); }); ++ finished[0]?.open(); ++ await delay(1); + await incoming.handle(segment(7, 2, 2)); + + assert.equal(answers.at(-1), 'ESME_ROK'); +- assert.equal(received.length, defaults.maxHeldMessages); ++ assert.equal(finished.length, bounds.maxRunningHandlers); + +- // A peer keeping its window full crosses the bound on every answer, and that is still one warning. +- await received[1]?.sendResp(); +- await new Promise(resolve => { setImmediate(resolve); }); +- await incoming.handle(submitPdu(defaults.maxHeldMessages + 2)); +- await incoming.handle(submitPdu(defaults.maxHeldMessages + 3)); +- await incoming.handle(submitPdu(defaults.maxHeldMessages + 4)); ++ // A peer keeping its window full crosses the bound on every finished handler, and that is still one warning. ++ finished[1]?.open(); ++ await delay(1); ++ await incoming.handle(submitPdu(bounds.maxRunningHandlers + 2)); ++ await incoming.handle(submitPdu(bounds.maxRunningHandlers + 3)); ++ await incoming.handle(submitPdu(bounds.maxRunningHandlers + 4)); + + assert.equal(answers.at(-1), 'ESME_RTHROTTLED'); + assert.equal(warnings.length, 1); +- incoming.clear(); ++ ++ for (const finish of finished) finish.open(); ++ ++ incoming.release(); + }); + +- test('holds a message detached from the chunk it was read from', async t => { ++ test('refuses a message where no handler takes them, so the peer is not left waiting', async t => { + const session = new Session({ sock: new net.Socket() }); + + closeAfter(t, session); + + const incoming = incomingOn(session); +- const chunk = Buffer.alloc(64 * 1024); +- const carried = submitPdu(1); +- let received: Sms | undefined; ++ const answers: (ErrorName | undefined)[] = []; + +- session.on('sms', sms => { received = sms; }); +- await incoming.handle({ ...carried, params: { ...carried.params, short_message: chunk.subarray(16, 20) } }); ++ session.sendReturn = (_pdu, status) => { ++ answers.push(status); + +- const retained = received?.pduObjs[0]?.params.short_message; ++ return Promise.resolve({}); ++ }; ++ await incoming.handle(submitPdu(1)); ++ await incoming.handle(segment(7, 1, 2)); + +- assert.ok(Buffer.isBuffer(retained)); +- assert.notEqual(retained.buffer, chunk.buffer); +- incoming.clear(); ++ assert.deepEqual(answers, ['ESME_RX_P_APPN', 'ESME_RX_P_APPN']); + }); + +- test('gives up on a message the application never answers', t => { +- let now = 0; +- const held = heldOn(t, { max: 10, maxOctets: 1_000_000, now: () => now, timeout: 60 }); ++ test('hands the handler a message detached from the chunk it was read from', async t => { ++ const session = new Session({ sock: new net.Socket() }); + +- offer(held, 1); +- now = 61; ++ closeAfter(t, session); + +- // The next message sweeps the one that expired, so only the new one is still waited for. +- offer(held, 2); ++ let received: Sms | undefined; ++ const incoming = incomingOn(session, { onSms: sms => { received = sms; } }); ++ const chunk = Buffer.alloc(64 * 1024); ++ const carried = submitPdu(1); + +- assert.equal(held.size, 1); ++ await incoming.handle({ ...carried, params: { ...carried.params, short_message: chunk.subarray(16, 20) } }); ++ ++ const retained = received?.pduObjs[0]?.params.short_message; + +- held.clear(); ++ assert.ok(Buffer.isBuffer(retained)); ++ assert.notEqual(retained.buffer, chunk.buffer); + }); + +- // Without this the drain sits out its whole budget before returning what a sweep already settled. +- test('wakes a waiting drain when the last message expires', async t => { +- let now = 0; +- const held = heldOn(t, { max: 10, maxOctets: 1_000_000, now: () => now, timeout: 60 }); ++ test('gives up on a handler that never finishes', async t => { ++ const session = new Session({ sock: new net.Socket() }); + +- offer(held, 1); ++ closeAfter(t, session); + +- const waiting = held.idle(1000, undefined); ++ const warnings: string[] = []; ++ const handlers = handlersOn(t, { ++ log: { ...silentLog, warn: message => { warnings.push(message); } }, ++ timeout: 30, ++ }); + +- now = 61; +- held.sweep(); ++ handlers.run(smsOn(session), () => new Promise(() => undefined)); + +- assert.equal(await waiting, 0); ++ assert.equal(handlers.size, 1); ++ await delay(50); ++ assert.equal(handlers.size, 0); ++ assert.equal(warnings.length, 1); + }); +-}); + +-describe('sendResp()', () => { +- // A response the wire never carried leaves the peer owed one, so nothing may count it answered. +- test('does not count a response that never reached the wire as an answer', async t => { ++ // Without this the drain sits out its whole budget before returning what a deadline already settled. ++ test('wakes a waiting drain when the last handler finishes or expires', async t => { + const session = new Session({ sock: new net.Socket() }); + + closeAfter(t, session); + +- let answered = 0; ++ const handlers = handlersOn(t, { timeout: 30 }); ++ const finish = runHeld(handlers, session); + +- session.sendReturn = () => Promise.resolve({ err: new Error('Socket is closed') }); ++ handlers.run(smsOn(session), () => new Promise(() => undefined)); + +- const sms = createSms({ +- pduObjs: [submitPdu(1)], +- session, +- }, { +- answered: () => { answered++; }, +- lostLink: () => false, +- send: () => Promise.resolve({ err: new Error('never sent') }), +- }); ++ const waiting = handlers.idle(1000, undefined); + +- assert.match((await sms.sendResp()).err?.message ?? '', /Socket is closed/); +- assert.equal(answered, 0); ++ finish.open(); ++ await delay(1); ++ assert.equal(handlers.size, 1, 'the one that never finishes is still counted'); ++ ++ assert.equal(await waiting, 0); + }); + }); + +@@ -1748,20 +1736,17 @@ describe('sendDlr()', () => { + const sms = createSms({ + pduObjs: [submitPdu(1), submitPdu(2), submitPdu(3)], + session, +- }, { +- answered: () => undefined, +- lostLink: () => false, +- send: () => { +- call++; ++ smsId: uuidv7(), ++ }, () => { ++ call++; + +- if (call === 1) return Promise.resolve({ pduObj: submitPdu(1, 'ESME_RX_T_APPN') }); ++ if (call === 1) return Promise.resolve({ pduObj: submitPdu(1, 'ESME_RX_T_APPN') }); + +- if (call === 2) { +- return Promise.resolve({ err: new UnansweredError(new Error('nothing came back')) }); +- } ++ if (call === 2) { ++ return Promise.resolve({ err: new UnansweredError(new Error('nothing came back')) }); ++ } + +- return Promise.resolve({ pduObj: submitPdu(3) }); +- }, ++ return Promise.resolve({ pduObj: submitPdu(3) }); + }); + const report = await sms.sendDlr('DELIVERED'); + +@@ -2351,7 +2336,7 @@ describe('a peer that sends the next segment only once the last one is answered' + params: submitSmParams( + { from: '46701113311', message: text, to: '46709771337' }, + segment, +- { encoding: 'ASCII', multipart: true }, ++ { encoding: 'GSM7', multipart: true }, + ), + }); + +@@ -2372,39 +2357,34 @@ describe('a peer that sends the next segment only once the last one is answered' + } + + test('gets every segment answered as it arrives, and the application one whole message', async t => { +- const smpp = await startServer(t); + const messages: Sms[] = []; +- const incoming = once(resolve => { +- smpp.on('session', bound => bound.on('sms', sms => { ++ const incoming = deferred(); ++ const smpp = await startServer(t, { ++ onSms: sms => { + messages.push(sms); +- resolve(sms); +- })); ++ incoming.resolve(sms); ++ }, + }); + const { session } = await connect(t, smpp, { responseTimeout: 1000 }); + + assert.ok(session); + + const answers = await submitSerially(session, 0x2A); +- const sms = await incoming; ++ const sms = await incoming.promise; + + assert.equal(sms.message, text); + assert.equal(sms.pduObjs.length, answers.length); + assert.equal(messages.length, 1, 'the application sees one message, not one per segment'); +- assert.equal(sms.answeredOnArrival, true); + assert.deepEqual(answers.map(answer => answer.cmdStatus), answers.map(() => 'ESME_ROK')); + assert.deepEqual( + answers.map(answer => paramText(answer.params.message_id)), + answers.map((_answer, index) => `${sms.smsId}-${String(index + 1)}`), + ); +- assert.deepEqual(await sms.sendResp(), {}); + }); + +- // The documented single-segment contract, which the segment-by-segment answer must not touch. +- test('answers a single-segment message only once the application does, with the id it chose', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', bound => bound.on('sms', resolve)); +- }); ++ test('answers a single-segment message on arrival, with the id the handler reads', async t => { ++ const incoming = deferred(); ++ const smpp = await startServer(t, { onSms: incoming.resolve }); + const { session } = await connect(t, smpp); + + assert.ok(session); +@@ -2413,54 +2393,24 @@ describe('a peer that sends the next segment only once the last one is answered' + cmdName: 'submit_sm', + params: { + destination_addr: '46709771337', +- short_message: 'one segment, answered by the application', ++ short_message: 'one segment, answered on arrival', + source_addr: '46701113311', + }, + }); +- const sms = await incoming; +- +- assert.equal(await within(150, submitted), undefined, 'nothing may answer for the application'); +- assert.equal(sms.answeredOnArrival, false); +- assert.deepEqual(await sms.sendResp({ smsId: '0199e0e9-4a3e-7c62-9a4b-1f0c5d7e8a21' }), {}); +- +- const answered = await submitted; +- +- assert.ok(answered.pduObj); +- assert.equal( +- paramText(answered.pduObj.params.message_id), +- '0199e0e9-4a3e-7c62-9a4b-1f0c5d7e8a21', +- ); +- }); +- +- test('refuses an id and a refusing status for segments already on the wire', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', bound => bound.on('sms', resolve)); +- }); +- const { session } = await connect(t, smpp, { responseTimeout: 1000 }); +- +- assert.ok(session); + +- await submitSerially(session, 0x2B); ++ const sms = await incoming.promise; ++ const response = await submitted; + +- const sms = await incoming; +- const named = await sms.sendResp({ smsId: '0199e0ea-1f3d-7ab4-8c21-6d4e5f0a9b73' }); +- const refused = await sms.sendResp({ status: 'ESME_RMSGQFUL' }); +- +- assert.match(named.err?.message ?? '', /fixed when its first segment arrived/); +- assert.match(refused.err?.message ?? '', /onRequest/); +- assert.deepEqual(await sms.sendResp({ status: 'ESME_ROK' }), {}); +- assert.equal(sms.answeredOnArrival, true); ++ assert.ok(response.pduObj); ++ assert.equal(response.pduObj.cmdStatus, 'ESME_ROK'); ++ assert.equal(paramText(response.pduObj.params.message_id), sms.smsId); + }); + + test('reports a half-arrived message it has already answered, and holds nothing after', async t => { +- const smpp = await startServer(t, { reassemblyTimeout: 60 }); + const messages: Sms[] = []; ++ const smpp = await startServer(t, { onSms: sms => { messages.push(sms); }, reassemblyTimeout: 60 }); + const lost = once(resolve => { +- smpp.on('session', bound => { +- bound.on('sessionError', resolve); +- bound.on('sms', sms => { messages.push(sms); }); +- }); ++ smpp.on('session', bound => { bound.on('sessionError', resolve); }); + }); + const { session } = await connect(t, smpp, { responseTimeout: 1000 }); + +@@ -2480,7 +2430,10 @@ describe('a peer that sends the next segment only once the last one is answered' + + test('reports a drop once as disconnected when a listener closes the session over the segments it lost', async t => { + const smpp = await startServer(t); +- const { session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } }); ++ const { session } = await connect(t, smpp, { ++ onSms: () => undefined, ++ reconnect: { maxDelay: 100, minDelay: 20 }, ++ }); + + assert.ok(session); + +@@ -2500,7 +2453,7 @@ describe('a peer that sends the next segment only once the last one is answered' + params: submitSmParams( + { from: '46701113311', message: text, to: '46709771337' }, + first, +- { encoding: 'ASCII', multipart: true }, ++ { encoding: 'GSM7', multipart: true }, + ), + }); + +@@ -2511,30 +2464,32 @@ describe('a peer that sends the next segment only once the last one is answered' + assert.deepEqual(events, ['disconnected', 'close']); + }); + +- test('close() still waits for a concatenated message the application has not answered', async t => { +- const smpp = await startServer(t, { shutdownTimeout: 50 }); +- const incoming = once(resolve => { +- smpp.on('session', bound => bound.on('sms', resolve)); ++ test('close() still waits for the handler of a concatenated message', async t => { ++ const incoming = deferred(); ++ const smpp = await startServer(t, { ++ onSms: sms => { ++ incoming.resolve(sms); ++ ++ return new Promise(() => undefined); ++ }, ++ shutdownTimeout: 50, + }); + const { session } = await connect(t, smpp, { responseTimeout: 1000 }); + + assert.ok(session); + + await submitSerially(session, 0x2D); +- await incoming; ++ await incoming.promise; + + const closed = await peerOf(smpp).close(); + + assert.ok(closed.err instanceof Error); +- assert.match(closed.err.message, /1 message\(s\) unanswered/); ++ assert.match(closed.err.message, /1 handler\(s\) still running/); + }); + +- // pduObjs.length is 1 either way here, so answeredOnArrival is the only thing that can say. +- test('marks a one-part concatenated message answered, as its segment count cannot', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', bound => bound.on('sms', resolve)); +- }); ++ test('answers a one-part concatenated message with the base id, as a whole message', async t => { ++ const incoming = deferred(); ++ const smpp = await startServer(t, { onSms: incoming.resolve }); + const { session } = await connect(t, smpp, { responseTimeout: 1000 }); + + assert.ok(session); +@@ -2551,24 +2506,19 @@ describe('a peer that sends the next segment only once the last one is answered' + source_addr: '46701113311', + }, + }); +- const sms = await incoming; ++ const sms = await incoming.promise; + + assert.equal(answered.err, undefined); + assert.ok(answered.pduObj); + assert.equal(answered.pduObj.cmdStatus, 'ESME_ROK'); + assert.equal(paramText(answered.pduObj.params.message_id), sms.smsId); + assert.equal(sms.pduObjs.length, 1); +- assert.equal(sms.answeredOnArrival, true); +- assert.deepEqual(await sms.sendResp(), {}); + }); + + // esm_class said there was a UDH, and there is no group its concatenation fields can join. + test('answers a segment whose UDH cannot be honoured rather than leaving the peer waiting', async t => { +- const smpp = await startServer(t); + const messages: Sms[] = []; +- +- smpp.on('session', bound => bound.on('sms', sms => { messages.push(sms); })); +- ++ const smpp = await startServer(t, { onSms: sms => { messages.push(sms); } }); + const { session } = await connect(t, smpp, { responseTimeout: 1000 }); + + assert.ok(session); +@@ -2594,11 +2544,8 @@ describe('a peer that sends the next segment only once the last one is answered' + + // Its esm_class is 0x00 and correct, so the refusal names the optional parameters instead. + test('refuses a sar_* segment the TLVs number impossibly by naming those TLVs', async t => { +- const smpp = await startServer(t); + const messages: Sms[] = []; +- +- smpp.on('session', bound => bound.on('sms', sms => { messages.push(sms); })); +- ++ const smpp = await startServer(t, { onSms: sms => { messages.push(sms); } }); + const { session } = await connect(t, smpp, { responseTimeout: 1000 }); + + assert.ok(session); +@@ -2662,13 +2609,10 @@ describe('AbortSignal on a send', () => { + t: TestContext, + options: Parameters[0] = {}, + ): Promise { +- const smpp = await startServer(t); ++ const smpp = await startServer(t, { onRequest: holdSubmits }); + const onWire = once(resolve => { + smpp.on('session', bound => bound.on('incomingPduObj', resolve)); + }); +- +- smpp.on('session', bound => bound.on('sms', () => undefined)); +- + const { session } = await connect(t, smpp, { maxOutstanding: 1, ...options }); + + assert.ok(session); +@@ -2716,24 +2660,25 @@ describe('AbortSignal on a send', () => { + }); + + test('leaves the freed slot to the next send rather than to the waiter that gave up', async t => { +- const smpp = await startServer(t); +- const holding = once(resolve => { smpp.on('session', bound => bound.on('sms', resolve)); }); ++ const holding = deferred<{ peer: Session; pduObj: PduObject }>(); + let firstTaken = false; +- +- smpp.on('session', bound => { +- bound.on('sms', async sms => { +- if (firstTaken) await sms.sendResp(); ++ const smpp = await startServer(t, { ++ onRequest: (peer, pduObj) => { ++ if (!isCommand(pduObj, 'submit_sm') || firstTaken) return false; + + firstTaken = true; +- }); +- }); ++ holding.resolve({ peer, pduObj }); + ++ return true; ++ }, ++ onSms: () => undefined, ++ }); + const { session } = await connect(t, smpp, { maxOutstanding: 1, responseTimeout: 10_000 }); + + assert.ok(session); + void session.sendSms({ from: '46701113311', message: 'holds the only slot', to: '46709771337' }); + +- const held = await holding; ++ const held = await holding.promise; + const controller = new AbortController(); + const abandoned = session.sendSms( + { from: '46701113311', message: 'abandoned in the queue', to: '46709771337' }, +@@ -2748,7 +2693,7 @@ describe('AbortSignal on a send', () => { + assert.ok(gaveUp, 'the waiter that gave up must settle before the slot it left is freed'); + // Any other error means it never reached the queue, so there was no waiter to strand. + assert.match(gaveUp.err?.message ?? '', /Aborted while waiting for a send window slot/); +- await held.sendResp(); ++ await held.peer.sendReturn(held.pduObj); + + const following = await within(1000, session.sendSms({ + from: '46701113311', +@@ -2762,27 +2707,60 @@ describe('AbortSignal on a send', () => { + }); + + describe('graceful shutdown', () => { ++ type HeldSubmit = { answer: (smsId: string) => Promise; pduObj: PduObject }; ++ ++ /** A submit the server holds unanswered through onRequest, so the client has a request in flight. */ + async function submitInFlight( + t: TestContext, + options: Parameters[0] = {}, + serverOptions: Parameters[0] = {}, +- message = 'answer me', + ) { +- const smpp = await startServer(t, serverOptions); +- const incoming = once(resolve => { +- smpp.on('session', bound => bound.on('sms', resolve)); ++ const held = deferred(); ++ const smpp = await startServer(t, { ++ ...serverOptions, ++ onRequest: (peer, pduObj) => { ++ if (!isCommand(pduObj, 'submit_sm')) return false; ++ ++ held.resolve({ answer: smsId => peer.sendReturn(pduObj, 'ESME_ROK', { message_id: smsId }), pduObj }); ++ ++ return true; ++ }, + }); + const { session } = await connect(t, smpp, options); + + assert.ok(session); + ++ const sent = session.sendSms({ from: '46701113311', message: 'answer me', to: '46709771337' }); ++ ++ return { held: await held.promise, sent, session, smpp }; ++ } ++ ++ /** A message whose onSms handler is still running, until the latch opens. */ ++ async function handlerRunning( ++ t: TestContext, ++ serverOptions: Parameters[0] = {}, ++ message = 'handle me', ++ ) { ++ const arrived = deferred(); ++ const release = latch(); ++ const smpp = await startServer(t, { ++ ...serverOptions, ++ onSms: async sms => { ++ arrived.resolve(sms); ++ await release.passed; ++ }, ++ }); ++ const { session } = await connect(t, smpp); ++ ++ assert.ok(session); ++ + const sent = session.sendSms({ from: '46701113311', message, to: '46709771337' }); + +- return { sent, session, sms: await incoming, smpp }; ++ return { release, sent, session, sms: await arrived.promise, smpp }; + } + + test('close() waits out a submit already on the wire and refuses new ones', async t => { +- const { sent, session, sms } = await submitInFlight(t); ++ const { held, sent, session } = await submitInFlight(t); + const closed = session.close(); + const refused = await session.sendSms({ + from: '46701113311', +@@ -2793,7 +2771,7 @@ describe('graceful shutdown', () => { + assert.ok(refused.err instanceof Error); + assert.equal(refused.err.message, 'Session is shutting down'); + +- await sms.sendResp({ smsId: 'answered-while-draining' }); ++ await held.answer('answered-while-draining'); + + assert.deepEqual((await sent).smsIds, ['answered-while-draining']); + assert.deepEqual(await closed, {}); +@@ -2801,73 +2779,95 @@ describe('graceful shutdown', () => { + + // The drain refuses sends; a response was never a send, and saying so is the more useful answer. + test('names a response put through send() as the misuse it is, even mid-shutdown', async t => { +- const { sent, session, sms } = await submitInFlight(t); ++ const { held, sent, session } = await submitInFlight(t); + const closing = session.close(); + const refused = await session.send({ cmdName: 'submit_sm_resp' }); + + assert.ok(refused.err instanceof Error); + assert.match(refused.err.message, /Use sendReturn\(\)/); + +- await sms.sendResp({ smsId: 'answered-after-the-misuse' }); ++ await held.answer('answered-after-the-misuse'); + + assert.deepEqual((await sent).smsIds, ['answered-after-the-misuse']); + assert.deepEqual(await closing, {}); + }); + + test('unbind() waits out a submit already on the wire before it unbinds', async t => { +- const { sent, session, sms } = await submitInFlight(t); ++ const { held, sent, session } = await submitInFlight(t); + const unbound = session.unbind(); + +- await sms.sendResp({ smsId: 'answered-before-unbind' }); ++ await held.answer('answered-before-unbind'); + + assert.deepEqual((await sent).smsIds, ['answered-before-unbind']); + assert.deepEqual(await unbound, {}); + }); + +- test('close() waits for a message the application has not answered yet', async t => { +- const { sent, smpp, sms } = await submitInFlight(t); ++ test('close() waits for a handler still running', async t => { ++ const { release, sent, smpp, sms } = await handlerRunning(t); + const closing = peerOf(smpp).close(); + + await delay(50); +- await sms.sendResp({ smsId: 'answered-during-the-inbound-drain' }); ++ release.open(); + + assert.deepEqual(await closing, {}); +- assert.deepEqual((await sent).smsIds, ['answered-during-the-inbound-drain']); ++ assert.deepEqual((await sent).smsIds, [sms.smsId]); + }); + +- test('gives up on a message the application never answers', async t => { +- const { smpp } = await submitInFlight(t, {}, { shutdownTimeout: 50 }); ++ test('gives up on a handler that never finishes', async t => { ++ const { smpp } = await handlerRunning(t, { shutdownTimeout: 50 }); + const closed = await peerOf(smpp).close(); + + assert.ok(closed.err instanceof Error); +- assert.match(closed.err.message, /1 message\(s\) unanswered/); ++ assert.match(closed.err.message, /1 handler\(s\) still running/); + }); + +- // Waiting forever is safe for the peer, which every request times out on. The application is not. +- test('falls back to responseTimeout for a held message when the shutdown waits forever', async t => { +- const { smpp } = await submitInFlight(t, {}, { responseTimeout: 200, shutdownTimeout: 0 }); ++ // The handler's own deadline bounds it, so a shutdown that waits forever still ends. ++ test('waits for a handler as long as it runs when the shutdown waits forever', async t => { ++ const { release, smpp } = await handlerRunning(t, { shutdownTimeout: 0 }); + const started = Date.now(); +- const closed = await peerOf(smpp).close(); +- const waited = Date.now() - started; ++ const closing = peerOf(smpp).close(); + +- assert.ok(closed.err instanceof Error); +- assert.match(closed.err.message, /1 message\(s\) unanswered/); +- assert.ok(waited >= 190, `waited ${String(waited)} ms, so the fallback was not what bounded it`); +- assert.ok(waited < 2000); ++ await delay(200); ++ release.open(); ++ ++ assert.deepEqual(await closing, {}); ++ assert.ok(Date.now() - started >= 190); + }); + + // leftOf() floors what is left at 1 ms: at 0 the request half would read "wait forever" instead. +- test('still ends when the message half has spent the whole shutdown budget', async t => { +- const { smpp } = await submitInFlight(t, {}, { shutdownTimeout: 100 }); +- const bound = peerOf(smpp); +- // The client listens for no 'sms', so this one is never answered and stays in the window. +- const unanswered = bound.send({ +- cmdName: 'submit_sm', +- params: { +- destination_addr: '46701113311', +- short_message: 'nothing answers this', +- source_addr: '46709771337', ++ test('still ends when the handler half has spent the whole shutdown budget', async t => { ++ const handling = deferred(); ++ const smpp = await startServer(t, { ++ onSms: sms => { ++ handling.resolve(sms); ++ ++ return new Promise(() => undefined); + }, ++ shutdownTimeout: 100, ++ }); ++ const arrived = once(resolve => { smpp.on('session', resolve); }); ++ const peer = net.connect({ port: smpp.port }); ++ const bind = objToPdu({ cmdName: 'bind_transceiver', params: { password: 'pass', system_id: 'user' }, seqNr: 1 }); ++ const submit = objToPdu({ ++ cmdName: 'submit_sm', ++ params: { destination_addr: '46709771337', short_message: 'never handled', source_addr: '46701113311' }, ++ seqNr: 2, ++ }); ++ ++ assert.ok(bind.buffer); ++ assert.ok(submit.buffer); ++ t.after(() => { peer.destroy(); }); ++ // The peer submits once bound, and answers nothing itself. ++ peer.once('data', () => { peer.write(submit.buffer); }); ++ peer.write(bind.buffer); ++ ++ const bound = await arrived; ++ ++ await handling.promise; ++ ++ const unanswered = bound.send({ ++ cmdName: 'deliver_sm', ++ params: { destination_addr: '46701113311', short_message: 'nothing answers this', source_addr: '46709771337' }, + }); + const closed = await Promise.race([ + bound.close(), +@@ -2876,14 +2876,13 @@ describe('graceful shutdown', () => { + }), + ]); + +- assert.match(closed.err?.message ?? '', /1 message\(s\) unanswered; .*1 request\(s\) unfinished/); ++ assert.match(closed.err?.message ?? '', /1 handler\(s\) still running; 1 request\(s\) unfinished/); + assert.ok((await unanswered).err instanceof Error); + }); + +- // The README's own listener answers and then sends its receipt, one turn later. Multipart, because +- // a receipt sent one-after-a-response outruns that turn on every segment past the first. +- test('a receipt sent right after the response still goes out mid-drain', async t => { +- const { sent, session, smpp, sms } = await submitInFlight(t, {}, {}, 'x'.repeat(400)); ++ // The README's own handler sends its receipt before it returns; every segment of it goes out. ++ test('a receipt a handler sends mid-drain still goes out', async t => { ++ const { release, sent, session, smpp, sms } = await handlerRunning(t, {}, 'x'.repeat(400)); + const received: Dlr[] = []; + const receipts = once(resolve => { + session.on('dlr', dlr => { +@@ -2894,109 +2893,65 @@ describe('graceful shutdown', () => { + }); + const closing = peerOf(smpp).close(); + +- assert.deepEqual(await sms.sendResp(), {}); ++ await delay(20); + + const receiptSent = await sms.sendDlr('DELIVERED'); + const ids = [1, 2, 3].map(part => `${sms.smsId}-${String(part)}`); + ++ release.open(); ++ + assert.equal(receiptSent.err, undefined); + assert.deepEqual((await receipts).map(dlr => dlr.smsId), ids); + assert.deepEqual(await closing, {}); + assert.deepEqual((await sent).smsIds, ids); + }); + +- test('a message no listener took does not hold the shutdown up', async t => { ++ test('a message no handler takes is refused, and holds the shutdown up for nothing', async t => { + const smpp = await startServer(t, { shutdownTimeout: 30_000 }); + const { session } = await connect(t, smpp); + + assert.ok(session); + +- const bound = peerOf(smpp); +- const arrived = once(resolve => { +- bound.on('incomingPduObj', pduObj => { +- if (pduObj.cmdName === 'submit_sm') resolve(pduObj); +- }); +- }); +- const sent = session.sendSms({ ++ const sent = await session.sendSms({ + from: '46701113311', + message: 'nobody is listening', + to: '46709771337', + }); + +- await arrived; +- await delay(50); ++ assert.equal(sent.err?.message, 'submit_sm refused by the peer: ESME_RX_P_APPN'); + + const started = Date.now(); + +- assert.deepEqual(await bound.close(), {}); ++ assert.deepEqual(await peerOf(smpp).close(), {}); + assert.ok(Date.now() - started < 1000); +- assert.ok((await sent).err instanceof Error); + }); + +- // emit() releases the hold of a listener that throws; one that rejects may cost no more than that. +- test('a listener that rejected before answering does not hold the shutdown up', async t => { +- const smpp = await startServer(t, { shutdownTimeout: 30_000 }); +- const failed = once(resolve => { +- smpp.on('session', bound => { +- bound.on('sessionError', resolve); +- bound.on('sms', () => Promise.reject(new Error('the listener gave up'))); +- }); ++ test('a handler that rejected does not hold the shutdown up', async t => { ++ const failed = deferred(); ++ const smpp = await startServer(t, { ++ onSms: () => Promise.reject(new Error('the handler gave up')), ++ shutdownTimeout: 30_000, + }); ++ ++ smpp.on('session', bound => { bound.on('sessionError', failed.resolve); }); ++ + const { session } = await connect(t, smpp); + + assert.ok(session); + + const sent = session.sendSms({ + from: '46701113311', +- message: 'the listener rejects', ++ message: 'the handler rejects', + to: '46709771337', + }); + +- assert.equal((await failed).message, 'the listener gave up'); ++ assert.equal((await failed.promise).message, 'the handler gave up'); + + const started = Date.now(); + + assert.deepEqual(await peerOf(smpp).close(), {}); + assert.ok(Date.now() - started < 1000); +- assert.ok((await sent).err instanceof Error); +- }); +- +- test('waits for the listener still working when another one rejected', async t => { +- const smpp = await startServer(t, { shutdownTimeout: 30_000 }); +- const failed = once(resolve => { +- smpp.on('session', bound => { +- bound.on('sessionError', resolve); +- bound.on('sms', async sms => { +- await delay(100); +- await sms.sendResp({ smsId: 'answered-after-the-other-gave-up' }); +- }); +- bound.on('sms', () => Promise.reject(new Error('the audit listener gave up'))); +- }); +- }); +- const { session } = await connect(t, smpp); +- +- assert.ok(session); +- +- const sent = session.sendSms({ +- from: '46701113311', +- message: 'two listeners, one gives up', +- to: '46709771337', +- }); +- +- assert.equal((await failed).message, 'the audit listener gave up'); +- assert.deepEqual(await peerOf(smpp).close(), {}); +- assert.deepEqual((await sent).smsIds, ['answered-after-the-other-gave-up']); +- }); +- +- // Nothing reached the peer, so a drain counting this answered would report an outcome that never was. +- test('leaves a message the library refused to answer unanswered', async t => { +- const { sms, smpp } = await submitInFlight(t, {}, { shutdownTimeout: 50 }); +- const refused = await sms.sendResp({ smsId: '' }); +- const closed = await peerOf(smpp).close(); +- +- assert.match(refused.err?.message ?? '', /smsId must not be empty/); +- assert.ok(closed.err instanceof Error); +- assert.match(closed.err.message, /1 message\(s\) unanswered/); ++ assert.equal((await sent).err, undefined, 'the message was answered before the handler ran'); + }); + + test('gives up on a request that outlasts shutdownTimeout', async t => { +@@ -3031,8 +2986,7 @@ describe('graceful shutdown', () => { + + // The queued segments are the whole reason the drain waits on the window and not on the pending map. + test('counts the segments still queued behind a full window', async t => { +- // No 'sms' listener, so the single-segment message holding the only slot is never answered. +- const smpp = await startServer(t); ++ const smpp = await startServer(t, { onRequest: holdSubmits }); + const onWire = once(resolve => { + smpp.on('session', bound => bound.on('incomingPduObj', resolve)); + }); +@@ -3182,7 +3136,7 @@ describe('graceful shutdown', () => { + closeAfter(t, session); + + const calls: string[] = []; +- const incoming = incomingOn(session); ++ const incoming = incomingOn(session, { onSms: () => undefined }); + const close = session.close.bind(session); + + session.sendReturn = pduObj => { +@@ -3208,12 +3162,15 @@ describe('message id notation', () => { + } + + test('correlates a hex submit_sm_resp against a decimal receipt', async t => { +- const smpp = await startServer(t); ++ const smpp = await startServer(t, { ++ onRequest: async (peer, pduObj) => { ++ if (!isCommand(pduObj, 'submit_sm')) return false; + +- smpp.on('session', bound => { +- bound.on('sms', sms => { void sms.sendResp({ smsId: '1a2b' }); }); +- }); ++ await peer.sendReturn(pduObj, 'ESME_ROK', { message_id: '1a2b' }); + ++ return true; ++ }, ++ }); + const { session } = await connect(t, smpp, { + smsIdFormat: { receipt: 'decimal', submitResp: 'hex' }, + }); +@@ -3238,13 +3195,8 @@ describe('message id notation', () => { + }); + + test('leaves the segment ids of a multipart send to merge as they are', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', bound => bound.on('sms', sms => { +- resolve(sms); +- void sms.sendResp(); +- })); +- }); ++ const incoming = deferred(); ++ const smpp = await startServer(t, { onSms: incoming.resolve }); + const { session } = await connect(t, smpp, { + smsIdFormat: { receipt: 'decimal', submitResp: 'hex' }, + }); +@@ -3253,7 +3205,7 @@ describe('message id notation', () => { + + const merged = once(resolve => { session.on('messageDlr', resolve); }); + const sent = await sendOne(session, 'x'.repeat(200)); +- const sms = await incoming; ++ const sms = await incoming.promise; + + assert.deepEqual(sent.smsIds, [1, 2].map(part => `${sms.smsId}-${String(part)}`)); + +diff --git a/test/session.test.ts b/test/session.test.ts +index 1f31f0b..a7db591 100644 +--- a/test/session.test.ts ++++ b/test/session.test.ts +@@ -1,28 +1,29 @@ + import assert from 'node:assert/strict'; + import net from 'node:net'; + import test, { describe } from 'node:test'; +-import type { Dlr } from '../src/dlr.ts'; +-import type { PduObject, PduObjectInput } from '../src/pdu.ts'; +-import type { Sms } from '../src/sms.ts'; ++import type { Dlr } from '../src/messages/dlr.ts'; ++import type { PduObject, PduObjectInput } from '../src/wire/pdu.ts'; ++import type { Sms } from '../src/messages/sms.ts'; + import type { ServerOptions, SmppServer } from '../src/server.ts'; + import type { SmppLog } from '../src/log.ts'; + import type { TestContext } from 'node:test'; + import type { VoidResult } from '../src/result.ts'; +-import { DlrMerger } from '../src/dlr-merger.ts'; +-import { PduFramer } from '../src/pdu-framer.ts'; +-import { ReconnectLoop } from '../src/reconnect-loop.ts'; +-import { Session, bindCommands } from '../src/session.ts'; +-import { checkSessionOptions } from '../src/session-options.ts'; ++import { DlrMerger } from '../src/messages/dlr-merger.ts'; ++import { PduFramer } from '../src/wire/pdu-framer.ts'; ++import { ReconnectLoop } from '../src/session/reconnect-loop.ts'; ++import { Session } from '../src/session.ts'; ++import { bindCommands } from '../src/session/bind-direction.ts'; ++import { checkSessionOptions } from '../src/session/session-options.ts'; + import { client } from '../src/client.ts'; + import { closeAfter, closeListenerAfter } from './teardown.ts'; + import { consts } from '../src/defs/constants.ts'; +-import { PduRefusedError } from '../src/pdu-refusal.ts'; +-import { isCommand, objToPdu, pduReturn, pduToObj } from '../src/pdu.ts'; ++import { PduRefusedError } from '../src/wire/pdu-refusal.ts'; ++import { isCommand, objToPdu, pduReturn, pduToObj } from '../src/wire/pdu.ts'; + import { paramText } from '../src/defs/types.ts'; + import { server } from '../src/server.ts'; + import { bareTlvHeader, pduBytes, shortened, truncatedTlv, withUnknownCmdId } from './raw-pdus.ts'; + import { silentLog } from '../src/log.ts'; +-import { splitMessage } from '../src/message.ts'; ++import { splitMessage } from '../src/messages/message.ts'; + + async function startServer( + t: TestContext, +@@ -53,6 +54,16 @@ function once(register: (resolve: (value: T) => void) => void): Promise { + return new Promise(resolve => { register(resolve); }); + } + ++/** An onSms handler and the first message it is handed. */ ++function firstSms(): { incoming: Promise; onSms: (sms: Sms) => void } { ++ let resolveSms: (sms: Sms) => void = () => undefined; ++ const incoming = once(resolve => { resolveSms = resolve; }); ++ ++ return { incoming, onSms: sms => { resolveSms(sms); } }; ++} ++ ++const uuidV7 = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; ++ + function delay(ms: number): Promise { + return new Promise(resolve => { setTimeout(resolve, ms); }); + } +@@ -507,11 +518,8 @@ describe('bind direction', () => { + }); + + test('refuses sendSms() on a receiver-bound session before it reaches the wire', async t => { +- const smpp = await startServer(t); + const arrived: Sms[] = []; +- +- smpp.on('session', peer => peer.on('sms', sms => arrived.push(sms))); +- ++ const smpp = await startServer(t, { onSms: sms => { arrived.push(sms); } }); + const { session } = await connect(t, smpp, { bindType: 'receiver' }); + + assert.ok(session); +@@ -542,20 +550,14 @@ describe('bind direction', () => { + }); + + test('refuses sendDlr() to a transmitter-bound peer before it reaches the wire', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', peer => peer.on('sms', resolve)); +- }); ++ const { incoming, onSms } = firstSms(); ++ const smpp = await startServer(t, { onSms }); + const { session } = await connect(t, smpp, { bindType: 'transmitter' }); + + assert.ok(session); + + const [sms] = await Promise.all([ +- incoming.then(async received => { +- await received.sendResp(); +- +- return received; +- }), ++ incoming, + session.sendSms({ dlr: true, from: '46701113311', message: 'one way', to: '46709771337' }), + ]); + const report = await sms.sendDlr(); +@@ -585,10 +587,7 @@ describe('bind direction', () => { + }); + + test('refuses a data_sm from a receiver-bound peer, and carries one from a transmitter', async t => { +- const smpp = await startServer(t); +- +- smpp.on('session', peer => peer.on('sms', sms => void sms.sendResp())); +- ++ const smpp = await startServer(t, { onSms: () => undefined }); + const receiving = await connect(t, smpp, { bindType: 'receiver' }); + + assert.ok(receiving.session); +@@ -619,23 +618,14 @@ describe('bind direction', () => { + + describe('sending', () => { + test('delivers a simple SMS with the sender TON derived from the address', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); ++ const { incoming, onSms } = firstSms(); ++ const smpp = await startServer(t, { onSms }); + const { session } = await connect(t, smpp); + + assert.ok(session); + + const [sms, sent] = await Promise.all([ +- incoming.then(async received => { +- const refused = await received.sendResp({ smsId: '' }); +- +- assert.ok(refused.err instanceof Error); +- await received.sendResp({ smsId: 'fixed-id' }); +- +- return received; +- }), ++ incoming, + session.sendSms({ from: 'MyBrand', message: 'hello world', to: '46709771337' }), + ]); + +@@ -643,9 +633,9 @@ describe('sending', () => { + assert.equal(sms.to, '46709771337'); + assert.equal(sms.message, 'hello world'); + assert.equal(sms.dlr, false); +- assert.equal(sms.smsId, 'fixed-id'); ++ assert.match(sms.smsId, uuidV7); + assert.equal(sent.err, undefined); +- assert.deepEqual(sent.smsIds, ['fixed-id']); ++ assert.deepEqual(sent.smsIds, [sms.smsId]); + + // 0.4.0 sent TON 1 for every sender, including alphanumeric ones, which require TON 5. + const submitted = sms.pduObjs[0]; +@@ -656,21 +646,15 @@ describe('sending', () => { + }); + + test('reassembles a long SMS and answers every segment', async t => { +- const smpp = await startServer(t); ++ const { incoming, onSms } = firstSms(); ++ const smpp = await startServer(t, { onSms }); + const message = 'Lorem ipsum dolor sit amet, '.repeat(20); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); + const { session } = await connect(t, smpp); + + assert.ok(session); + + const [sms, sent] = await Promise.all([ +- incoming.then(async received => { +- await received.sendResp(); +- +- return received; +- }), ++ incoming, + session.sendSms({ from: '46701113311', message, to: '46709771337' }), + ]); + +@@ -682,43 +666,31 @@ describe('sending', () => { + }); + + test('carries a UCS2 message through unchanged', async t => { +- const smpp = await startServer(t); ++ const { incoming, onSms } = firstSms(); ++ const smpp = await startServer(t, { onSms }); + const message = 'räksmörgås تست 一'; +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); + const { session } = await connect(t, smpp); + + assert.ok(session); + + const [sms] = await Promise.all([ +- incoming.then(async received => { +- await received.sendResp(); +- +- return received; +- }), ++ incoming, + session.sendSms({ from: '46701113311', message, to: '46709771337' }), + ]); + + assert.equal(sms.message, message); +- assert.match(sms.smsId, /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); ++ assert.match(sms.smsId, uuidV7); + }); + + test('marks a flash message without losing the UCS2 alphabet', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); ++ const { incoming, onSms } = firstSms(); ++ const smpp = await startServer(t, { onSms }); + const { session } = await connect(t, smpp); + + assert.ok(session); + + const [sms] = await Promise.all([ +- incoming.then(async received => { +- await received.sendResp(); +- +- return received; +- }), ++ incoming, + session.sendSms({ flash: true, from: '46701113311', message: 'تست', to: '46709771337' }), + ]); + +@@ -729,20 +701,14 @@ describe('sending', () => { + }); + + test('puts the address TON and NPI the caller chose on the wire', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', peer => peer.on('sms', resolve)); +- }); ++ const { incoming, onSms } = firstSms(); ++ const smpp = await startServer(t, { onSms }); + const { session } = await connect(t, smpp); + + assert.ok(session); + + const [sms] = await Promise.all([ +- incoming.then(async received => { +- await received.sendResp(); +- +- return received; +- }), ++ incoming, + session.sendSms({ + destinationAddrNpi: consts.NPI.ISDN, + destinationAddrTon: consts.TON.NATIONAL, +@@ -767,21 +733,21 @@ describe('sending', () => { + describe('receiving', () => { + async function inbound( + t: TestContext, +- options: ServerOptions = {}, +- ): Promise<{ peer: Session; session: Session }> { +- const smpp = await startServer(t, options); ++ options: { onSms?: (sms: Sms) => void; server?: ServerOptions } = {}, ++ ): Promise<{ incoming: Promise; peer: Session; session: Session }> { ++ const first = firstSms(); ++ const smpp = await startServer(t, options.server ?? {}); + + const bound = once(resolve => { smpp.on('session', resolve); }); +- const { session } = await connect(t, smpp); ++ const { session } = await connect(t, smpp, { onSms: options.onSms ?? first.onSms }); + + assert.ok(session); + +- return { peer: await bound, session }; ++ return { incoming: first.incoming, peer: await bound, session }; + } + + test('hands a client a deliver_sm that is not a delivery receipt', async t => { +- const { peer, session } = await inbound(t); +- const incoming = once(resolve => { session.on('sms', resolve); }); ++ const { incoming, peer } = await inbound(t); + const delivered = peer.send({ + cmdName: 'deliver_sm', + params: { +@@ -797,8 +763,6 @@ describe('receiving', () => { + assert.equal(sms.to, '46709771337'); + assert.equal(sms.message, 'inbound hello'); + +- await sms.sendResp({ smsId: 'inbound-id' }); +- + const answered = await delivered; + + assert.ok(answered.pduObj); +@@ -808,13 +772,12 @@ describe('receiving', () => { + '', + 'SMPP 3.4 4.6.2 leaves deliver_sm_resp\'s message_id unused', + ); +- assert.equal(sms.smsId, 'inbound-id', 'the id the application chose is still its own handle'); ++ assert.match(sms.smsId, uuidV7, 'the id the peer was not told is still the application\'s handle'); + }); + + // SMPP 3.4 5.3.2.32: up to 64 KB of body in a TLV, with sm_length 0 and short_message empty. + test('reads an inbound message the peer carried in message_payload', async t => { +- const { peer, session } = await inbound(t); +- const incoming = once(resolve => { session.on('sms', resolve); }); ++ const { incoming, peer } = await inbound(t); + const text = 'the whole body, carried in the TLV'; + const delivered = peer.send({ + cmdName: 'deliver_sm', +@@ -832,8 +795,6 @@ describe('receiving', () => { + assert.equal(sms.from, '46701113311'); + assert.equal(sms.to, '46709771337'); + +- await sms.sendResp(); +- + const answered = await delivered; + + assert.ok(answered.pduObj); +@@ -841,9 +802,7 @@ describe('receiving', () => { + }); + + test('hands a client a data_sm carrying a message as an sms, answered data_sm_resp', async t => { +- const { peer, session } = await inbound(t); +- const incoming = once(resolve => { session.on('sms', resolve); }); +- const smsId = '0199e0f1-6c31-7a44-9d02-4b7e51c3a806'; ++ const { incoming, peer } = await inbound(t); + const delivered = peer.send({ + cmdName: 'data_sm', + params: { destination_addr: '46709771337', source_addr: '46701113311' }, +@@ -855,25 +814,20 @@ describe('receiving', () => { + assert.equal(sms.message, 'a message carried on the data command'); + assert.equal(sms.from, '46701113311'); + +- await sms.sendResp({ smsId }); +- + const answered = await delivered; + + assert.ok(answered.pduObj); + assert.equal(answered.pduObj.cmdName, 'data_sm_resp'); + assert.equal(answered.pduObj.cmdStatus, 'ESME_ROK'); + // SMPP 3.4 4.7.2 gives data_sm_resp a message_id, where 4.6.2 leaves deliver_sm_resp's unused. +- assert.equal(paramText(answered.pduObj.params.message_id), smsId); ++ assert.equal(paramText(answered.pduObj.params.message_id), sms.smsId); + }); + + test('hands a client a receipt carried on data_sm as a dlr', async t => { +- const { peer, session } = await inbound(t); ++ let messages = 0; ++ const { peer, session } = await inbound(t, { onSms: () => { messages++; } }); + const reported = once(resolve => { session.on('dlr', resolve); }); + const smsId = '0199e0f1-b8a2-7f19-8c63-2d5041fb9e77'; +- let messages = 0; +- +- session.on('sms', () => { messages++; }); +- + const delivered = peer.send({ + cmdName: 'data_sm', + params: { +@@ -903,18 +857,12 @@ describe('receiving', () => { + + // At the SMSC end an inbound data_sm is a submission, so nothing in one reports on our own sends. + test('reads a receipt-shaped data_sm submitted to a server as the message it is', async t => { +- const smpp = await startServer(t); + const body = 'id:0199e0f2-2d15-7b83-a4c1-6e90b7d2f345 stat:DELIVRD err:000 text:'; + const messages: Sms[] = []; + const reports: Dlr[] = []; ++ const smpp = await startServer(t, { onSms: sms => { messages.push(sms); } }); + +- smpp.on('session', peer => { +- peer.on('dlr', dlr => { reports.push(dlr); }); +- peer.on('sms', sms => { +- messages.push(sms); +- void sms.sendResp(); +- }); +- }); ++ smpp.on('session', peer => { peer.on('dlr', dlr => { reports.push(dlr); }); }); + + const { session } = await connect(t, smpp, { bindType: 'transmitter' }); + +@@ -932,15 +880,15 @@ describe('receiving', () => { + + assert.ok(submitted.pduObj); + assert.equal(submitted.pduObj.cmdStatus, 'ESME_ROK'); +- assert.notEqual(paramText(submitted.pduObj.params.message_id), ''); + assert.equal(messages[0]?.message, body); ++ assert.equal(paramText(submitted.pduObj.params.message_id), messages[0].smsId); + assert.deepEqual(reports, [], 'an ESME submitting is never the network reporting'); + }); + + // The refusal a submission gets is the one submit_sm_resp defines, whichever command carried it. + test('refuses a data_sm segment a server has no room for with the submit code', async t => { + // The two addresses and the objects are 1322 octets, so the 6-octet UDH and its text are what overrun 1330. +- const smpp = await startServer(t, { maxOctets: 1330 }); ++ const smpp = await startServer(t, { maxOctets: 1330, onSms: () => undefined }); + const { session } = await connect(t, smpp, { bindType: 'transmitter' }); + + assert.ok(session); +@@ -965,8 +913,7 @@ describe('receiving', () => { + }); + + test('reassembles a concatenated message whose segments arrived in message_payload', async t => { +- const { peer, session } = await inbound(t); +- const incoming = once(resolve => { session.on('sms', resolve); }); ++ const { incoming, peer } = await inbound(t); + const text = 'A body in the TLV is still numbered by its UDH. '.repeat(6); + const segments = splitMessage(text, { reference: 0x3B }); + +@@ -994,23 +941,18 @@ describe('receiving', () => { + + assert.ok(sms, 'the segments join into one message wherever their bodies were carried'); + assert.equal(sms.message, text); +- assert.equal(sms.answeredOnArrival, true); + assert.deepEqual(answers.map(answer => answer.cmdStatus), answers.map(() => 'ESME_ROK')); + assert.deepEqual( + answers.map(answer => paramText(answer.params.message_id)), + answers.map(() => ''), + 'SMPP 3.4 4.6.2 leaves deliver_sm_resp\'s message_id unused, segment by segment too', + ); +- assert.deepEqual(await sms.sendResp(), {}); + }); + + test('hands a client a report as a dlr rather than as an sms', async t => { +- const { peer, session } = await inbound(t); +- const reported = once(resolve => { session.on('dlr', resolve); }); + let messages = 0; +- +- session.on('sms', () => { messages++; }); +- ++ const { peer, session } = await inbound(t, { onSms: () => { messages++; } }); ++ const reported = once(resolve => { session.on('dlr', resolve); }); + const delivered = peer.send({ + cmdName: 'deliver_sm', + params: { +@@ -1083,10 +1025,9 @@ describe('receiving', () => { + assert.ok((await delivered).pduObj); + }); + +- test('reassembles a multipart inbound SMS before the sms event', async t => { ++ test('reassembles a multipart inbound SMS before the sms handler', async t => { + const message = 'Inbound lorem ipsum dolor sit amet consectetur, '.repeat(6); +- const { peer, session } = await inbound(t); +- const incoming = once(resolve => { session.on('sms', resolve); }); ++ const { incoming, peer } = await inbound(t); + const segments = splitMessage(message, { reference: 42 }); + + assert.equal(segments.length, 2); +@@ -1106,8 +1047,6 @@ describe('receiving', () => { + assert.equal(sms.message, message); + assert.equal(sms.pduObjs.length, 2); + +- assert.deepEqual(await sms.sendResp(), {}); +- + for (const answered of await delivered) { + assert.ok(answered.pduObj); + assert.equal(paramText(answered.pduObj.params.message_id), ''); +@@ -1124,8 +1063,8 @@ describe('receiving', () => { + } + + test('answers every sar_* segment on arrival and hands the application one message', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { smpp.on('session', peer => peer.on('sms', resolve)); }); ++ const { incoming, onSms } = firstSms(); ++ const smpp = await startServer(t, { onSms }); + const { session } = await connect(t, smpp, { bindType: 'transmitter', responseTimeout: 1000 }); + + assert.ok(session); +@@ -1153,7 +1092,6 @@ describe('receiving', () => { + + assert.ok(sms, 'the sar_* TLVs tie the three submissions into one message'); + assert.equal(sms.message, parts.join('')); +- assert.equal(sms.answeredOnArrival, true); + assert.deepEqual( + answers.map(answer => paramText(answer.params.message_id)), + [1, 2, 3].map(part => `${sms.smsId}-${String(part)}`), +@@ -1161,8 +1099,7 @@ describe('receiving', () => { + }); + + test('joins sar_* segments in the order they number themselves', async t => { +- const { peer, session } = await inbound(t, { responseTimeout: 1000 }); +- const incoming = once(resolve => { session.on('sms', resolve); }); ++ const { incoming, peer } = await inbound(t, { server: { responseTimeout: 1000 } }); + const parts = ['first ', 'second ', 'third']; + + for (const index of [2, 0, 1]) { +@@ -1187,8 +1124,7 @@ describe('receiving', () => { + }); + + test('reassembles a sar_* segment whose body is in message_payload', async t => { +- const { peer, session } = await inbound(t, { responseTimeout: 1000 }); +- const incoming = once(resolve => { session.on('sms', resolve); }); ++ const { incoming, peer } = await inbound(t, { server: { responseTimeout: 1000 } }); + const parts = ['in the mandatory field, ', 'and in the TLV']; + + for (const [index, part] of parts.entries()) { +@@ -1218,16 +1154,15 @@ describe('receiving', () => { + + assert.ok(sms, 'a segment carries its body where any other message may carry one'); + assert.equal(sms.message, parts.join('')); +- assert.equal(sms.answeredOnArrival, true); + }); + + // The UDH reference is 8 bits and sar_msg_ref_num is 16, so the same number is two messages. + test('keeps a UDH group and a sar_* group sharing a reference apart', async t => { +- const { peer, session } = await inbound(t, { responseTimeout: 1000 }); + const messages: Sms[] = []; +- +- session.on('sms', sms => { messages.push(sms); }); +- ++ const { peer } = await inbound(t, { ++ onSms: sms => { messages.push(sms); }, ++ server: { responseTimeout: 1000 }, ++ }); + const udhText = 'the message numbered by its user data header. '.repeat(5); + const udhSegments = splitMessage(udhText, { reference: 5 }); + const sarParts = ['the message numbered by ', 'its optional parameters']; +@@ -1271,8 +1206,7 @@ describe('receiving', () => { + + // Nothing compares the two references: each spelling counts in a space of its own. + test('groups a segment carrying both spellings by its UDH', async t => { +- const { peer, session } = await inbound(t, { responseTimeout: 1000 }); +- const incoming = once(resolve => { session.on('sms', resolve); }); ++ const { incoming, peer } = await inbound(t, { server: { responseTimeout: 1000 } }); + const message = 'both spellings on every segment of it, and the UDH decides. '.repeat(4); + const segments = splitMessage(message, { reference: 7 }); + +@@ -1301,12 +1235,14 @@ describe('receiving', () => { + }); + + test('reads a receipt carrying sar_* fields as a dlr, never as a segment', async t => { +- const { peer, session } = await inbound(t, { responseTimeout: 1000 }); + const reports: Dlr[] = []; + let messages = 0; ++ const { peer, session } = await inbound(t, { ++ onSms: () => { messages++; }, ++ server: { responseTimeout: 1000 }, ++ }); + + session.on('dlr', dlr => { reports.push(dlr); }); +- session.on('sms', () => { messages++; }); + + const marked = '0199e1a4-6c3f-7d21-9a80-5b1e2f7c4d63'; + const unmarked = '0199e1a4-b70e-7c55-8f42-9d3a1c86e70b'; +@@ -1350,10 +1286,8 @@ describe('receiving', () => { + + describe('delivery reports', () => { + test('reaches the sender as a dlr event', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); ++ const { incoming, onSms } = firstSms(); ++ const smpp = await startServer(t, { onSms }); + const { session } = await connect(t, smpp); + + assert.ok(session); +@@ -1362,31 +1296,26 @@ describe('delivery reports', () => { + session.on('dlr', (report, pduObj) => { resolve([report, pduObj]); }); + }); + +- const [sms] = await Promise.all([ +- incoming.then(async received => { +- await received.sendResp({ smsId: 'dlr-id' }); +- +- return received; +- }), ++ const [sms, sent] = await Promise.all([ ++ incoming, + session.sendSms({ dlr: true, from: '46701113311', message: 'hi', to: '46709771337' }), + ]); + + assert.ok(sms.dlr); ++ assert.deepEqual(sent.smsIds, [sms.smsId]); + await sms.sendDlr(); + + const [report, receipt] = await dlr; + +- assert.equal(report.smsId, 'dlr-id'); ++ assert.equal(report.smsId, sms.smsId); + assert.equal(report.statusMsg, 'DELIVERED'); +- assert.equal(receipt.tlvs.receipted_message_id?.tagValue, 'dlr-id'); ++ assert.equal(receipt.tlvs.receipted_message_id?.tagValue, sms.smsId); + assert.equal(receipt.tlvs.message_state?.tagValue, 2); + }); + + test('reports a failure with the spec status code', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); ++ const { incoming, onSms } = firstSms(); ++ const smpp = await startServer(t, { onSms }); + const { session } = await connect(t, smpp); + + assert.ok(session); +@@ -1401,11 +1330,7 @@ describe('delivery reports', () => { + }); + + const [sms] = await Promise.all([ +- incoming.then(async received => { +- await received.sendResp({ smsId: 'fail-id' }); +- +- return received; +- }), ++ incoming, + session.sendSms({ dlr: true, from: '46701113311', message: 'hi', to: '46709771337' }), + ]); + +@@ -1417,10 +1342,8 @@ describe('delivery reports', () => { + }); + + test('sends a text-only receipt to a peer that declared less than 3.4', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); ++ const { incoming, onSms } = firstSms(); ++ const smpp = await startServer(t, { onSms }); + const peer = rawPeer(t, smpp.port); + + peer.write(bindOf(0x33)); +@@ -1439,9 +1362,10 @@ describe('delivery reports', () => { + }); + + const sms = await incoming; ++ const submitted = await peer.next(); + +- await sms.sendResp(); +- await peer.next(); ++ assert.equal(submitted.cmdName, 'submit_sm_resp'); ++ assert.equal(paramText(submitted.params.message_id), sms.smsId); + + // A raw peer answers no deliver_sm, so this only settles once the session closes. + void sms.sendDlr(); +@@ -1454,10 +1378,8 @@ describe('delivery reports', () => { + }); + + test('merges nothing for a message that asked for no receipt', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); ++ const { incoming, onSms } = firstSms(); ++ const smpp = await startServer(t, { onSms }); + const { session } = await connect(t, smpp); + + assert.ok(session); +@@ -1469,11 +1391,7 @@ describe('delivery reports', () => { + session.on('messageDlr', () => { merged++; }); + + const [sms] = await Promise.all([ +- incoming.then(async received => { +- await received.sendResp(); +- +- return received; +- }), ++ incoming, + session.sendSms({ from: '46701113311', message: 'x'.repeat(400), to: '46709771337' }), + ]); + +@@ -1497,11 +1415,8 @@ describe('a session captured from Kannel', () => { + const expected = 'Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry\'s standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum'; + + async function replay(t: TestContext, order: number[]): Promise { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); +- ++ const { incoming, onSms } = firstSms(); ++ const smpp = await startServer(t, { onSms }); + const sock = net.connect({ port: smpp.port }, () => { + sock.write(Buffer.from('0000002100000009000000000000002f666f6f0062617200736d70700034000000', 'hex')); + }); +@@ -1522,11 +1437,7 @@ describe('a session captured from Kannel', () => { + } + }); + +- const sms = await incoming; +- +- await sms.sendResp(); +- +- return sms; ++ return incoming; + } + + test('reassembles four segments arriving in order', async t => { +@@ -1598,21 +1509,21 @@ describe('robustness', () => { + }); + + test('keeps at most maxOutstanding requests on the wire', async t => { +- const smpp = await startServer(t); + let concurrent = 0; + let peak = 0; ++ const smpp = await startServer(t, { ++ onRequest: async (bound, pduObj) => { ++ if (!isCommand(pduObj, 'submit_sm')) return false; + +- smpp.on('session', session => { +- session.on('sms', sms => { + concurrent++; + peak = Math.max(peak, concurrent); +- setTimeout(() => { +- concurrent--; +- void sms.sendResp(); +- }, 10); +- }); +- }); ++ await delay(10); ++ concurrent--; ++ await bound.sendReturn(pduObj, 'ESME_ROK', { message_id: String(pduObj.seqNr) }); + ++ return true; ++ }, ++ }); + const { session } = await connect(t, smpp, { maxOutstanding: 2 }); + + assert.ok(session); +@@ -1653,12 +1564,7 @@ describe('robustness', () => { + }); + + test('ignores events from the socket it left behind on a reconnect', async t => { +- const smpp = await startServer(t); +- +- smpp.on('session', bound => { +- bound.on('sms', sms => { void sms.sendResp(); }); +- }); +- ++ const smpp = await startServer(t, { onSms: () => undefined }); + const { session } = await connect(t, smpp, { reconnect: { maxDelay: 50, minDelay: 10 } }); + + assert.ok(session); +@@ -1731,11 +1637,8 @@ describe('robustness', () => { + + // The guard sits before the send window, or a full window makes the aborted call queue first. + test('does not wait for a send window slot it will never use', async t => { +- const smpp = await startServer(t); +- + // The peer answers nothing, so the one slot stays held for the whole test. +- smpp.on('session', session => session.on('sms', () => undefined)); +- ++ const smpp = await startServer(t, { onRequest: (_bound, pduObj) => isCommand(pduObj, 'submit_sm') }); + const { session } = await connect(t, smpp, { maxOutstanding: 1, responseTimeout: 5000 }); + + assert.ok(session); +@@ -1972,13 +1875,10 @@ describe('application hooks that throw or reject', () => { + assert.equal(reported.message, 'authenticate exploded'); + }); + +- test('turns a throwing sms listener into a session error', async t => { +- const smpp = await startServer(t); ++ test('turns a throwing sms handler into a session error', async t => { ++ const smpp = await startServer(t, { onSms: () => { throw new Error('handler exploded'); } }); + const failed = once(resolve => { +- smpp.on('session', session => { +- session.on('sessionError', resolve); +- session.on('sms', () => { throw new Error('listener exploded'); }); +- }); ++ smpp.on('session', session => { session.on('sessionError', resolve); }); + }); + const { session } = await connect(t, smpp, { responseTimeout: 200 }); + +@@ -1986,23 +1886,22 @@ describe('application hooks that throw or reject', () => { + + const sent = await session.sendSms({ + from: '46701113311', +- message: 'blows up the listener', ++ message: 'blows up the handler', + to: '46709771337', + }); + const reported = await raceWithin(500, failed); + +- assert.ok(sent.err instanceof Error); +- assert.ok(reported instanceof Error, 'a throwing sms listener should reach the session'); +- assert.equal(reported.message, 'listener exploded'); ++ assert.equal(sent.err, undefined, 'the message was answered before the handler ran'); ++ assert.ok(reported instanceof Error, 'a throwing sms handler should reach the session'); ++ assert.equal(reported.message, 'handler exploded'); + }); + +- // The guard for a throwing sms listener used to emit sessionError from inside its own catch. ++ // The guard for a throwing sms handler used to emit sessionError from inside its own catch. + test('survives a sessionError listener that throws as well', async t => { +- const smpp = await startServer(t); ++ const smpp = await startServer(t, { onSms: () => { throw new Error('handler exploded'); } }); + + smpp.on('session', session => { + session.on('sessionError', () => { throw new Error('the reporter exploded too'); }); +- session.on('sms', () => { throw new Error('listener exploded'); }); + }); + + const { session } = await connect(t, smpp, { responseTimeout: 200 }); +@@ -2015,21 +1914,21 @@ describe('application hooks that throw or reject', () => { + to: '46709771337', + }); + +- assert.ok(sent.err instanceof Error); ++ assert.equal(sent.err, undefined); ++ assert.equal((await session.send({ cmdName: 'enquire_link' })).err, undefined); + }); + +- test('normalises whatever a rejecting async sms listener threw into a session error', async t => { +- const smpp = await startServer(t); ++ test('normalises whatever a rejecting async sms handler threw into a session error', async t => { + const reason: unknown = null; +- const failed = once(resolve => { +- smpp.on('session', session => { +- session.on('sessionError', resolve); +- session.on('sms', async sms => { +- await sms.sendResp(); ++ const smpp = await startServer(t, { ++ onSms: async () => { ++ await delay(1); + +- throw reason; +- }); +- }); ++ throw reason; ++ }, ++ }); ++ const failed = once(resolve => { ++ smpp.on('session', session => { session.on('sessionError', resolve); }); + }); + const { session } = await connect(t, smpp, { responseTimeout: 200 }); + +@@ -2043,16 +1942,15 @@ describe('application hooks that throw or reject', () => { + const reported = await raceWithin(500, failed); + + assert.equal(sent.err, undefined); +- assert.ok(reported instanceof Error, 'a rejecting sms listener should reach the session'); ++ assert.ok(reported instanceof Error, 'a rejecting sms handler should reach the session'); + assert.equal(reported.message, 'null'); + }); + + test('survives a sessionError listener that rejects as well', async t => { +- const smpp = await startServer(t); ++ const smpp = await startServer(t, { onSms: () => Promise.reject(new Error('handler rejected')) }); + + smpp.on('session', session => { + session.on('sessionError', () => Promise.reject(new Error('the reporter rejected too'))); +- session.on('sms', () => Promise.reject(new Error('listener rejected'))); + }); + + const { session } = await connect(t, smpp, { responseTimeout: 200 }); +@@ -2065,7 +1963,8 @@ describe('application hooks that throw or reject', () => { + to: '46709771337', + }); + +- assert.ok(sent.err instanceof Error); ++ assert.equal(sent.err, undefined); ++ assert.equal((await session.send({ cmdName: 'enquire_link' })).err, undefined); + }); + + test('turns a rejecting session listener into a server error', async t => { +@@ -2099,12 +1998,7 @@ describe('application hooks that throw or reject', () => { + test('sends on through an application logger that throws', async t => { + const thrower = (): void => { throw new Error('the logger exploded'); }; + const log: SmppLog = { debug: thrower, error: thrower, info: thrower, verbose: thrower, warn: thrower }; +- const smpp = await startServer(t, { log }); +- +- smpp.on('session', session => { +- session.on('sms', sms => { void sms.sendResp(); }); +- }); +- ++ const smpp = await startServer(t, { log, onSms: () => undefined }); + const { session } = await connect(t, smpp, { log }); + + assert.ok(session); +@@ -2130,12 +2024,15 @@ describe('application hooks that throw or reject', () => { + }); + + test('keeps the message id off a submit_sm_resp that refuses the message', async t => { +- const smpp = await startServer(t); ++ const smpp = await startServer(t, { ++ onRequest: async (bound, pduObj) => { ++ if (!isCommand(pduObj, 'submit_sm')) return false; + +- smpp.on('session', bound => { +- bound.on('sms', sms => { void sms.sendResp({ status: 'ESME_RMSGQFUL' }); }); +- }); ++ await bound.sendReturn(pduObj, 'ESME_RMSGQFUL'); + ++ return true; ++ }, ++ }); + const peer = rawPeer(t, smpp.port); + + peer.write(bindOf(0x34)); +@@ -2269,6 +2166,7 @@ describe('the server\'s onRequest hook', () => { + } + + test('refuses an inbound submit_sm with the status the hook chose', async t => { ++ let messages = 0; + const smpp = await startServer(t, { + onRequest: async (bound, pduObj) => { + if (!isCommand(pduObj, 'submit_sm')) return false; +@@ -2277,11 +2175,8 @@ describe('the server\'s onRequest hook', () => { + + return true; + }, ++ onSms: () => { messages++; }, + }); +- let messages = 0; +- +- smpp.on('session', bound => bound.on('sms', () => { messages++; })); +- + const { session } = await connect(t, smpp); + + assert.ok(session); +@@ -2291,7 +2186,7 @@ describe('the server\'s onRequest hook', () => { + assert.equal(answered.err, undefined); + assert.ok(answered.pduObj); + assert.equal(answered.pduObj.cmdStatus, 'ESME_RINVDSTADR'); +- assert.equal(messages, 0, 'a request the hook answered never reaches the sms event'); ++ assert.equal(messages, 0, 'a request the hook answered never reaches the sms handler'); + }); + + test('answers a bind itself, so a hook that claims every request cannot intercept one', async t => { +@@ -2332,16 +2227,12 @@ describe('the server\'s onRequest hook', () => { + assert.deepEqual(seen, [], 'a bind, and everything a peer sends before one, is never the hook\'s'); + }); + +- test('passes a declined request to the sms event, and offers the keepalive and the unbind too', async t => { ++ test('passes a declined request to the sms handler, and offers the keepalive and the unbind too', async t => { + const seen: string[] = []; ++ const { incoming, onSms } = firstSms(); + const smpp = await startServer(t, { + onRequest: (_bound, pduObj) => { seen.push(pduObj.cmdName); return false; }, +- }); +- const incoming = once(resolve => { +- smpp.on('session', bound => bound.on('sms', async sms => { +- resolve(sms); +- await sms.sendResp({ smsId: answeredId }); +- })); ++ onSms, + }); + const { session } = await connect(t, smpp); + +@@ -2355,22 +2246,20 @@ describe('the server\'s onRequest hook', () => { + + assert.ok(answered.pduObj); + assert.equal(answered.pduObj.cmdStatus, 'ESME_ROK'); +- assert.equal(paramText(answered.pduObj.params.message_id), answeredId); ++ assert.equal(paramText(answered.pduObj.params.message_id), sms.smsId); + assert.equal(sms.message, 'declined by the hook'); + assert.deepEqual(seen, ['submit_sm', 'enquire_link', 'unbind']); + }); + + test('reports a hook that throws and answers nothing for it', async t => { ++ let messages = 0; + const smpp = await startServer(t, { + onRequest: () => { throw new Error('the onRequest hook exploded'); }, ++ onSms: () => { messages++; }, + }); + const failed = once(resolve => { + smpp.on('session', bound => { bound.on('sessionError', resolve); }); + }); +- let messages = 0; +- +- smpp.on('session', bound => bound.on('sms', () => { messages++; })); +- + const { session } = await connect(t, smpp, { responseTimeout: 300 }); + + assert.ok(session); +@@ -2384,16 +2273,14 @@ describe('the server\'s onRequest hook', () => { + }); + + test('reports a hook that rejects and answers nothing for it', async t => { ++ let messages = 0; + const smpp = await startServer(t, { + onRequest: () => Promise.reject(new Error('the onRequest hook rejected')), ++ onSms: () => { messages++; }, + }); + const failed = once(resolve => { + smpp.on('session', bound => { bound.on('sessionError', resolve); }); + }); +- let messages = 0; +- +- smpp.on('session', bound => bound.on('sms', () => { messages++; })); +- + const { session } = await connect(t, smpp, { responseTimeout: 300 }); + + assert.ok(session); +@@ -2704,18 +2591,23 @@ describe('option validation', () => { + test('refuses a hook that is not a function at startup, rather than once per PDU', async () => { + const badAuth: ServerOptions = { port: 0 }; + const badHook: ServerOptions = { port: 0 }; ++ const badHandler: ServerOptions = { port: 0 }; + + Object.assign(badAuth, { authenticate: 'yes please' }); + Object.assign(badHook, { onRequest: 'refuse them all' }); ++ Object.assign(badHandler, { onSms: 'read them all' }); + + const authRefused = await server(badAuth); + const hookRefused = await server(badHook); ++ const handlerRefused = await server(badHandler); + + if (authRefused.server) await authRefused.server.close(); + if (hookRefused.server) await hookRefused.server.close(); ++ if (handlerRefused.server) await handlerRefused.server.close(); + + assert.match(authRefused.err?.message ?? '', /authenticate must be a function/); + assert.match(hookRefused.err?.message ?? '', /onRequest must be a function/); ++ assert.match(handlerRefused.err?.message ?? '', /onSms must be a function/); + }); + + test('refuses a reassembly octet cap below 1 at startup', async () => { +diff --git a/test/sms-id.test.ts b/test/sms-id.test.ts +index 0240cc1..20036b3 100644 +--- a/test/sms-id.test.ts ++++ b/test/sms-id.test.ts +@@ -1,6 +1,6 @@ + import assert from 'node:assert/strict'; + import test, { describe } from 'node:test'; +-import { normaliseSmsId, parseSegmentId, respIdParams, segmentId } from '../src/sms-id.ts'; ++import { normaliseSmsId, parseSegmentId, respIdParams, segmentId } from '../src/messages/sms-id.ts'; + + describe('normaliseSmsId()', () => { + test('reads an id the length a message_id may be, and leaves a longer one alone', () => { +diff --git a/test/teardown.ts b/test/teardown.ts +index 70632b6..661fde1 100644 +--- a/test/teardown.ts ++++ b/test/teardown.ts +@@ -1,4 +1,4 @@ +-import type { CloseOptions } from '../src/session-options.ts'; ++import type { CloseOptions } from '../src/session/session-options.ts'; + import type { Server, Socket } from 'node:net'; + import type { TestContext } from 'node:test'; + +diff --git a/test/tls.test.ts b/test/tls.test.ts +index 1e311f4..a87c00d 100644 +--- a/test/tls.test.ts ++++ b/test/tls.test.ts +@@ -1,8 +1,9 @@ + import assert from 'node:assert/strict'; + import net from 'node:net'; + import test, { describe } from 'node:test'; +-import type { Sms } from '../src/sms.ts'; ++import type { Sms } from '../src/messages/sms.ts'; + import type { SmppServer } from '../src/server.ts'; ++import type { SmsHandler } from '../src/session/session-options.ts'; + import type { TestContext } from 'node:test'; + import { Log } from '@larvit/log'; + import { TLSSocket } from 'node:tls'; +@@ -104,8 +105,9 @@ function createCertificate(): { cert: string; key: string } { + + const certificate = createCertificate(); + +-async function startServer(t: TestContext): Promise { ++async function startServer(t: TestContext, onSms?: SmsHandler): Promise { + const { err, server: smpp } = await server({ ++ ...(onSms ? { onSms } : {}), + port: 0, + tls: { cert: certificate.cert, key: certificate.key }, + }); +@@ -117,16 +119,20 @@ async function startServer(t: TestContext): Promise { + return smpp; + } + +-function once(register: (resolve: (value: T) => void) => void): Promise { +- return new Promise(resolve => { register(resolve); }); ++type Deferred = { promise: Promise; resolve: (value: T) => void }; ++ ++/** A promise settled from the outside, for a handler that has to exist before what fires it. */ ++function deferred(): Deferred { ++ let resolve: (value: T) => void = () => undefined; ++ const promise = new Promise(settle => { resolve = settle; }); ++ ++ return { promise, resolve }; + } + + describe('tls', () => { + test('binds over a verified handshake and delivers an SMS', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); ++ const received = deferred(); ++ const smpp = await startServer(t, received.resolve); + const { err, session } = await client({ + host, + port: smpp.port, +@@ -145,18 +151,14 @@ describe('tls', () => { + assert.equal(sock.getPeerCertificate().subject.CN, host); + + const [sms, sent] = await Promise.all([ +- incoming.then(async received => { +- await received.sendResp({ smsId: 'tls-id' }); +- +- return received; +- }), ++ received.promise, + session.sendSms({ from: 'MyBrand', message: 'hello over tls', to: '46709771337' }), + ]); + + assert.equal(sms.message, 'hello over tls'); + assert.equal(sms.to, '46709771337'); + assert.equal(sent.err, undefined); +- assert.deepEqual(sent.smsIds, ['tls-id']); ++ assert.deepEqual(sent.smsIds, [sms.smsId]); + + assert.deepEqual(await session.unbind(), {}); + }); +@@ -191,12 +193,11 @@ describe('tls', () => { + }); + + test('logs a handshake the server turned away', async t => { +- let onWarning: ((message: string) => void) | undefined; +- const warned = once(resolve => { onWarning = resolve; }); ++ const warned = deferred(); + const log = new Log({ + logLevel: 'warn', +- stderr: message => onWarning?.(message), +- stdout: message => onWarning?.(message), ++ stderr: warned.resolve, ++ stdout: warned.resolve, + }); + const { err, server: smpp } = await server({ + log, +@@ -213,6 +214,6 @@ describe('tls', () => { + t.after(() => { sock.destroy(); }); + sock.resume(); + +- assert.match(await warned, /client handshake failed/); ++ assert.match(await warned.promise, /client handshake failed/); + }); + }); +diff --git a/test/unsendable.test.ts b/test/unsendable.test.ts +index 3108910..aaaf2c7 100644 +--- a/test/unsendable.test.ts ++++ b/test/unsendable.test.ts +@@ -1,14 +1,14 @@ + import assert from 'node:assert/strict'; + import test, { describe } from 'node:test'; +-import type { PduObjectInput } from '../src/pdu.ts'; +-import type { SendSmsDeps, SendSmsInput } from '../src/send-sms.ts'; ++import type { PduObjectInput } from '../src/wire/pdu.ts'; ++import type { SendSmsDeps, SendSmsInput } from '../src/messages/send-sms.ts'; + import { bindToSmsc, dummySmsc } from './dummy-smsc.ts'; +-import { decodeMessage } from '../src/message.ts'; +-import { messageOctets } from '../src/message-body.ts'; +-import { objToPdu, pduToObj } from '../src/pdu.ts'; ++import { decodeMessage } from '../src/messages/message.ts'; ++import { messageOctets } from '../src/messages/message-body.ts'; ++import { objToPdu, pduToObj } from '../src/wire/pdu.ts'; + import { paramNumber, paramText } from '../src/defs/types.ts'; + import { silentLog } from '../src/log.ts'; +-import { submitSms } from '../src/send-sms.ts'; ++import { submitSms } from '../src/messages/send-sms.ts'; + + const from = '46701113311'; + const to = '46709771337'; +@@ -56,12 +56,12 @@ describe('an alphabet the caller named that cannot carry the message', () => { + }); + + // Å is in the GSM table at 0x0E; ï is the one the encoder flattened to a space. +- test('refuses an ASCII send of a character GSM 03.38 has no code for, naming that one', async () => { ++ test('refuses a GSM7 send of a character GSM 03.38 has no code for, naming that one', async () => { + const attempts: PduObjectInput[] = []; +- const sent = await submitSms(recordingDeps(attempts), { encoding: 'ASCII', from, message: 'Åsa naïve', to }); ++ const sent = await submitSms(recordingDeps(attempts), { encoding: 'GSM7', from, message: 'Åsa naïve', to }); + + assert.ok(sent.err instanceof Error); +- assert.match(sent.err.message, /ASCII/); ++ assert.match(sent.err.message, /GSM7/); + assert.match(sent.err.message, /"ï"/); + assert.match(sent.err.message, /U\+00EF/); + assert.match(sent.err.message, /index 6/); +@@ -151,7 +151,7 @@ describe('a body the PDU\'s own data_coding cannot carry', () => { + }); + + assert.ok(built.err instanceof Error, String(dataCoding)); +- assert.match(built.err.message, /ASCII/); ++ assert.match(built.err.message, /GSM7/); + assert.match(built.err.message, /"ï"/); + assert.match(built.err.message, /U\+00EF/); + assert.match(built.err.message, /index 6/); +diff --git a/todo.md b/todo.md +index 3e5b9c7..3f28cdd 100644 +--- a/todo.md ++++ b/todo.md +@@ -25,12 +25,12 @@ const { err, session } = await client({ host, password, port, username }); + const { err: sendErr, pduObjs, smsIds } = await session.sendSms({ dlr, from, message, to }); + await session.unbind(); + +-const { err: serverErr, server: smpp } = await server({ authenticate, port }); +-smpp.on('session', session => { +- session.on('sms', async sms => { +- await sms.sendResp(); ++const { err: serverErr, server: smpp } = await server({ ++ authenticate, ++ onSms: async sms => { + if (sms.dlr) await sms.sendDlr('DELIVERED'); +- }); ++ }, ++ port, + }); + await smpp.close(); + ``` +@@ -57,12 +57,12 @@ Rules the API follows: + | Merged multipart DLRs including across a reconnect, reassembly bounds, per-send abort, the segment cap | `test/session-extras.test.ts` | + | `smsIdFormat`: a peer's `submit_sm_resp` and receipt ids read into one notation before they are compared | `test/dlr.test.ts`, `test/session-extras.test.ts` | + | A draining `close()` and `unbind()`, bounded by `shutdownTimeout` or an abort | `test/session-extras.test.ts` | +-| A drain that also waits out the messages the application has not answered, with `sendDlr()` the one send that passes it | `test/session-extras.test.ts` | +-| `OutgoingRequests`: the gate, the window, the pending map and the retry under one owner, told when a link comes up or goes down | `test/session-extras.test.ts`, `test/session.test.ts` | +-| Held messages capped and expiring, so an application that answers nothing cannot grow them | `test/session-extras.test.ts` | ++| A drain that also waits out the `onSms` handlers still running, with `sendDlr()` let past it | `test/session-extras.test.ts` | ++| `OutgoingRequests`: one `request()` in three lanes, the window, the pending map and the retry under one owner | `test/session-extras.test.ts`, `test/session.test.ts` | ++| Running handlers capped and expiring, so a handler that never settles cannot grow them | `test/session-extras.test.ts` | + | A send with no link held for the next one, and one the link dropped under counted as `unanswered` | `test/session-extras.test.ts` | +-| A message whose link dropped refused an answer, with its receipt still allowed out | `test/session-extras.test.ts` | +-| The hold released exactly when the peer was answered: a refused `sendResp()` keeps it, a listener that rejected drops it | `test/session-extras.test.ts` | ++| A receipt for a message whose link dropped still goes out on the new link | `test/session-extras.test.ts` | ++| Every message answered on arrival, before its handler runs; refused where no handler takes it | `test/session-extras.test.ts` | + | Every runnable README example | `test/readme.test.ts` | + | Receipt-versus-message classification by `esm_class` | `test/dlr.test.ts`, `test/session.test.ts` | + | An intermediate delivery notification read as a report marked `intermediate`, as is a receipt reporting `ENROUTE` or `SCHEDULED`, and never counted into a merge | `test/dlr.test.ts`, `test/session.test.ts`, `test/session-extras.test.ts` | +@@ -268,15 +268,6 @@ below. + README.md or in MIGRATION.md — write it in the same change as the rule, so it is worded once. + From the stability and product-owner reviews of #18. + +-- [ ] **Build every error from a thrown value through `errorFrom()`.** `reconnect-loop.ts` (in +- `run()`'s catch and in `bringUp()`) and `client.ts`'s connect still call `String(thrown)`, +- which throws on a null-prototype object; in `run()` that lands as an unhandled rejection from +- a `void`ed promise, against hard rule 1. From the 2026-09-28 scoring run. +- +-- [ ] **Leave `sms.smsId` alone when `sendResp()` fails.** `sms.ts` assigns `options.smsId` before +- the lost-link check and before the write, so a failed answer still renames the message and a +- later `sendDlr()` names an id the peer was never given. From the 2026-09-28 scoring run. +- + - [ ] **Refuse a timeout past 2³¹−1 ms, as `connectTimeout` already is.** `checkLimits()` bounds + `responseTimeout`, `idleTimeout`, `shutdownTimeout` and `reassemblyTimeout` from below only, + and Node fires a larger delay after 1 ms. From the 2026-09-28 scoring run. +@@ -285,11 +276,6 @@ below. + so `"\x1B("` is detected as GSM, goes out as 0x1B 0x28 and arrives as `{`. From the + 2026-09-28 scoring run. + +-- [ ] **Count an `sms` listener that throws as one giving up, as a rejection already is.** A +- synchronous throw makes `Session.emit` return false, and `HeldMessages.offer()` then releases +- the hold at once, so a drain stops waiting on an async listener still answering beside it. +- Goal 2. From the stability review of #45. +- + ### Throughput — goal 6, and the default window is where we are slowest + + - [ ] **Close the gap to jsmpp at `maxOutstanding: 10`.** Measured 2026-09-20 against the same sink, +@@ -301,50 +287,12 @@ below. + + ### Shape — 6 today, and the gate is 7 + +-- [ ] **Answer "is this a bind command" in one place.** `bindCommands` (read by +- `incoming-requests.ts`, `outgoing-requests.ts` and `test/session.test.ts`) and +- `bindTypeFromCommand()` (read by `server.ts` and `checkedBind()`) each list the three bind +- commands, so a fourth added to one is missed by the other. Derive the list from the function, +- or the reverse. From the stability review of #42. +- +-- [ ] **Group `src/` into a second level, and retire whichever record loses.** 34 files on one +- plane, where `src/defs/` at 7 proves the shape is known one level down. `docs/decisions.md` +- says "`src/` stays flat until a module has to move for another reason. Valid while that map is +- what a reader navigates by" — and both architects reported that the map is now AGENTS.md rather +- than the tree, which is that premise failing. `todo.md` already carries the opposite +- instruction under Worth doing. Two records, opposite answers; one has to go. +- + - [ ] **Split `test/session-extras.test.ts` by the question each block answers.** 3,010 lines, 19 + unrelated `describe` blocks whose names are already the file names they should be. With + `session.test.ts` it is 54% of all test code and 84% the size of `src/`. "extras" names neither + a question nor a module — it names the rest — and AGENTS.md's own convention forbids exactly + that. `max-lines` covers `src/**` only, so nothing has stopped it growing. + +-- [ ] **Collapse the three objects named `defaults`.** `client.ts`, `server.ts` and +- `session-options.ts` each export or hold one; `port: 2775` is written twice and the idle +- timeout is derived two ways to the same 40 000, and 64 MiB is both `defaultMaxOctets` and +- `defaults.maxHeldOctets`. "What is the default for X" has three answers +- depending on the entrypoint, and nothing fails when they drift. Named by both architects as the +- most likely first bug a new contributor ships. +- +-- [ ] **Give `hold` one meaning, and rename `IncomingRequests.refusing` for what it does.** +- `LinkLife.hold()` is a request's budget waiting for a link, `HeldMessages.hold()` a message the +- application owes an answer; `refusing` decides no refusal — `held.full()` does — and only makes +- the warn and info lines fire once each way. From the 2026-09-29 scoring run. +- +-- [ ] **Rename `EncodingName`'s `ASCII` to `GSM7`, with `ASCII` a deprecated alias for one minor.** +- It is GSM 03.38, where `$` is 0x02 and `@` is 0x00, and `segmentUnits.ASCII = 153` is a septet +- budget under a name that says octets. The 2026-09-09 decision removed `consts.ENCODING.ASCII` +- for exactly this reason and left the option's own vocabulary carrying it. Pre-1.0 the minor is +- the breaking unit, so this is as cheap as it will ever be, and `todo.md` already requires the +- `consts.ENCODING` names settled before the custom-encoding registry — this is the other half. +- +-- [ ] **Split `session-options.ts` into the things it is.** Option types and their validator, the +- `SessionEvents` map, and the bind-direction rules (`bindCommands`, `bindTypeFromCommand`, +- `standsInFor`, `bindCarries`) are three questions in one file, and the `defaults` table mixes +- option defaults with four hard bounds that are not options. Both architects named it as where +- the codebase rots first: at 34-wide it is where anything session-shaped lands. +- + - [ ] **Name the base-versus-segment distinction in the message id types.** `Sms.smsId` is a base, + `sendSms().smsIds[]` are segment ids, `Dlr.smsId` is a segment id and `MessageDlr.smsId` is a + base again — four fields, one type, `string`. The whole multipart receipt mechanism turns on +@@ -371,10 +319,6 @@ below. + option"). A sender with more than 1000 concurrent multipart `dlr: true` messages silently + evicts the oldest at `warn`. The inherited architect hit this on the 3am walk. + +-- [ ] **State in README that `SmppServer.close()` reports each session's unfinished drain as +- `serverError`.** Only `docs/decisions.md` says so; README's Shutdown section covers the +- session's own result alone. +- + - [ ] **Add a ten-line SMPP glossary to the README.** Both juniors and the no-domain mid reported + the same largest cost: nothing in the repo says what a PDU, `esm_class`, `data_coding`, TON/NPI + or `submit_sm`-versus-`deliver_sm` are, and the inline spec citations mark a rule without +@@ -406,15 +350,6 @@ below. + - [ ] **Name the goal and the premise of every `docs/decisions.md` entry.** The prose sweep of #48 + counted 30 of 58 entries naming no goal and 52 with no "valid while" premise. + +-- [ ] **Make `LinkLife` start unbound, or its decision's title true.** A link attached but not +- yet bound cannot carry a request, while `phase` starts `up`, so the first link and a server +- session are up before any bind. The `LinkLife` decision in `docs/decisions.md` makes the same claim +- in its title, and carries the same fix. From the comprehension panel of #25. +- +-- [ ] **Move `checkSessionOptions()`'s doc comment to what it describes.** It explains why a count +- below 1 is refused, which is `checkLimits`' job, and says nothing of the function it heads. +- From the comprehension panel of #25. +- + - [ ] **Log why `DlrMerger.expect()` registered no merge.** Ids with no common `-` + numbering return silently, the likeliest cause of a `messageDlr` that never fires and the one + that leaves no trace. From the comprehension panel of #25. diff --git a/docs/comprehension-rewrite/drafts/draft-d.patch b/docs/comprehension-rewrite/drafts/draft-d.patch new file mode 100644 index 0000000..fe4c7ad --- /dev/null +++ b/docs/comprehension-rewrite/drafts/draft-d.patch @@ -0,0 +1,6204 @@ +diff --git a/AGENTS.md b/AGENTS.md +index 882bb24..06cc397 100644 +--- a/AGENTS.md ++++ b/AGENTS.md +@@ -40,14 +40,16 @@ src/ + index.ts Public surface. Named exports only, no default export. + 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 +- sms.ts The live handle emitted as the 'sms' event (sendResp/sendDlr) ++ session.ts Session: the link's life (linkLost, finish, comeBackUp), the drain, dispatch and events ++ sms.ts The live handle handed to onSms (sendResp/sendDlr) ++ bind-direction.ts Bind types and commands, which end of the link this is, what a bind direction carries + concat.ts How a PDU says it is a segment: its UDH, or the sar_* TLVs ++ defaults.ts Every default the session layer runs on, in one object + dlr.ts Delivery receipts: text and TLV parsing, receipt status codes + dlr-merger.ts DlrMerger: per-segment receipts counted into one MessageDlr + 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 ++ expiring-groups.ts ExpiringGroups: the capped, weighed, expiring store DlrMerger, HandledMessages and Reassembler share ++ handled-messages.ts HandledMessages: the messages whose onSms handler is running, capped, expiring, waited on by a drain + idle-waiters.ts IdleWaiters: waiting for a count to fall to zero, and what is left of a budget + incoming-requests.ts Every request the peer sends: messages, receipts, links, unknown commands + link-life.ts LinkLife: whether the link lives, and where a request waits for the next one +@@ -55,7 +57,7 @@ src/ + 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: request() through the link wait and the window, requestOnLink() for a bind or unbind + 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 +69,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, OnSms, OnRequest, and the option checks + 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 +@@ -84,8 +86,10 @@ src/ + + Imports point one way: `defs` knows nothing above it but `result.ts`, `pdu` uses `defs`, `session` + uses `pdu`, and `client`/`server` use `session`. The ways back up are the `Session` handed to +-`createSms()`, `HeldMessages` and `IncomingRequests`, which call back into it, and to `OnRequest` +-and `onConnected` in `session-options.ts`, all imported as a type only. ++`IncomingRequests` (the hook's argument, the bind predicates, `emit` and `close`) and to ++`createSms()` (the public `sms.session`), and to `OnRequest`, `OnSms` and `onConnected` in ++`session-options.ts`, all imported as a type only. Everything else a collaborator does to the ++session is a named closure in its options: `answer`, `sendReceipt`, `report`. + + **Parameter order is wire order.** The key order inside `cmds.*.params` is the order the fields are + written to and read from the buffer. Never sort those alphabetically — the alphabetical-ordering +@@ -224,6 +228,7 @@ this is not a changelog. + + ### [The public surface](docs/decisions.md#the-public-surface) + ++- A request the application must answer is a handler option; a fact it may watch is an event. + - `Session` is publicly constructible, which is what makes `SessionOptions` and `ReconnectOptions` + public too. + - `acceptsOptionalParams()` and `bindAllows()` are predicates, not chokepoints. +@@ -288,15 +293,15 @@ this is not a changelog. + - A stream this library cannot frame is a dead link; one PDU it cannot parse is not. + - A deliberate shutdown drains; an unusable link and an abort do not. + - `sendSms()` puts every segment of a message on the wire together. +-- Every segment of a concatenated message is answered as it arrives, so `sendResp()` on one is the +- application's own signal rather than the peer's answer. ++- Every segment of a concatenated message is answered as it arrives, so `sendResp()` on one writes ++ nothing. + - `server()` composes the application's `onRequest` after its own bind handling, and offers it every + request that handling did not answer. +-- The drain waits on the messages the application holds, and `sendResp()` is what says it is done +- with one. +-- The drain's wait on the application ignores `shutdownTimeout: 0`. +-- What the application holds unanswered is capped on constants, and a message past the cap is +- refused. ++- The drain waits on the `onSms` handlers still running, and a handler's promise is what says it ++ is done with a message. ++- A handler that fails before answering has the message refused for it; one that fails after has ++ its answer stand. ++- What the running handlers hold is capped on constants, and a message past the cap is refused. + - A store at its bound answers `ESME_RTHROTTLED` to a submission and `ESME_RX_T_APPN` to a delivery, + a `data_sm` by whichever it stands in for. + - A reconnect keeps the delivery-receipt merges; everything else the link held is dropped. +diff --git a/CHANGELOG.md b/CHANGELOG.md +index 7cb5ff7..ab02284 100644 +--- a/CHANGELOG.md ++++ b/CHANGELOG.md +@@ -2,6 +2,25 @@ + + ## 0.6.0 (unreleased) + ++- **Inbound messages go to an `onSms` handler option, and the `sms` event is gone.** Give it to ++ `client()`, `server()` or `Session`; `sms.session` says which session a server's message came in ++ on. The message is held — counted toward the bound, and waited for by `close()` — until the ++ promise the handler returns settles. A handler that throws or rejects before answering has the ++ message refused for it (`ESME_RTHROTTLED` on a submission, `ESME_RX_T_APPN` on a delivery), so the ++ peer retries; with no `onSms` at all, every message is refused that way and reported on ++ `sessionError`. The release used to come from `sendResp()`, one turn later, or from every ++ listener rejecting. ++- `close()` and `unbind()` refuse new `sendSms()` and `send()` calls, and let `sendResp()` and ++ `sendDlr()` through for as long as the session lives, so a receipt sent after the answer goes out ++ wherever in the handler it is sent. `shutdownTimeout: 0` now waits for a handler as long as it ++ waits for a request; it used to fall back to `responseTimeout` for the messages. ++- `sendResp()` refuses a second call on the same message, and a call that failed leaves `sms.smsId` ++ as it was. ++- `encoding: 'GSM'` names the GSM 03.38 alphabet, in `sendSms()`, `EncodingName`, `encodings`, ++ `dataCodingByEncoding`, `detect()` and the rest. It was `'ASCII'`, which named the one thing the ++ alphabet is not. ++- A client whose rebind was answered on a link the peer had already dropped no longer comes up on ++ the dead socket; the reconnect loop retries instead. + - `client()` now bounds each connect attempt at 10 seconds, the TLS handshake included, and reports + one that expires as an ordinary connect failure, so `reconnect` retries it on its usual backoff. + A connect previously waited the operating system out, around 130 s on Linux against a host that +@@ -42,9 +61,9 @@ + the cap is lost, since its segments were already answered. + - A message arriving while the application holds 1000 unanswered, or 64 MiB of them counted the way + `maxOctets` counts segments, is refused with `ESME_RTHROTTLED` (`ESME_RX_T_APPN` on a +- delivery), so the peer keeps it and retries. **Call `sendResp()` on every `sms`, multipart +- included**: 1000 left unanswered now stop inbound traffic for up to five minutes, where the oldest +- used to be dropped with a warning. ++ delivery), so the peer keeps it and retries. **Return from `onSms` on every message, multipart ++ included**: 1000 handlers still running now stop inbound traffic for up to five minutes, where the ++ oldest message used to be dropped with a warning. + - A `submit_sm` segment the reassembly buffer has no room for is refused with `ESME_RTHROTTLED`, + where it was `ESME_RMSGQFUL`. + - `server()` refuses a `maxOctets` below 1 or not a whole number, `Infinity` included, like its +diff --git a/DESIGN.md b/DESIGN.md +new file mode 100644 +index 0000000..66a8b5d +--- /dev/null ++++ b/DESIGN.md +@@ -0,0 +1,83 @@ ++# Draft D: the message handler is the contract ++ ++Round one showed the internals were as simple as the `sms` event allowed: a hold with six exits, ++three of them about how many listeners there were and how they failed. This draft changes the ++contract so the hold has one exit, and the internals follow. ++ ++## The contract, as an implementer sees it ++ ++- **Receiving.** `onSms: sms => Promise | void`, an option on `client()`, `server()` and ++ `Session`. One handler per session; a server's handler sees every session, `sms.session` says which. ++- **Answering.** `await sms.sendResp()` once, `sendResp({ smsId, status })` to name or refuse. A ++ multipart message was answered per segment as it arrived (`sms.answeredOnArrival`), so there it ++ writes nothing. A second call is refused. `sms.sendDlr()` sends the receipt, before or after the ++ handler returns. ++- **What happens if you don't.** Return without answering: the peer waits on you, the shutdown does ++ not. Throw or reject before answering: the message is refused with the retry status and reported ++ on `sessionError`. Throw after answering: the answer stands. No `onSms`: every message is refused ++ the same way. Never return: after five minutes the message stops counting. ++- **Bound.** 1000 running handlers, or 64 MiB of messages under them, refuse new messages until half. ++- **Sending.** `sendSms()`/`send()` wait for a bound link and a window slot; what never reached the ++ socket waits for the next link, what did is `unanswered`. Unchanged. ++- **Shutdown.** `close()`/`unbind()`: refuse new `sendSms()`/`send()`; `sendResp()`/`sendDlr()` ++ still go out. Wait up to `shutdownTimeout` for running handlers, then for requests on the wire. ++ `0` waits forever for both. Tear down, report what did not finish. ++- **Events** are facts nothing waits on: `dlr`, `messageDlr`, `close`, `disconnected`, ++ `reconnected`, `sessionError`, `data`, `incomingPdu`, `incomingPduObj`. Rule in README: a request ++ you must answer is a handler; a fact you may watch is an event. ++- `encoding: 'GSM'` names GSM 03.38. ++ ++## Internal structure and ownership ++ ++| Module | Owns | ++| --- | --- | ++| `session.ts` | The link's life in three methods: `linkLost()` (socket close, unreadable stream, idle, failed rebind — one entry, decides retry or end), `finish()` (once; drop, clear merges, `close`), `comeBackUp()`. `drain()` is the shutdown in eleven lines. Collaborators get named closures (`answer`, `sendReceipt`, `report`), the session itself only where the public contract needs it (`sms.session`, the hook's argument). | ++| `handled-messages.ts` | `HandledMessages`: the whole hold. `offer()` creates the `Sms`, runs the handler, releases on settle; answers a failed handler's message; `refuses()` with its hysteresis; `idle()` for the drain; `sweep()`/`clear()`. | ++| `incoming-requests.ts` | Routing only, and synchronous but for the hook and `close()`: hook, link-gone check, bind gate, one switch, reassembly, offer. | ++| `outgoing-requests.ts` | `request()`: one path (refusal, link wait, slot, attempt, retry where nothing reached the socket), with `pastDrain` for a receipt. `requestOnLink()`: a bind or unbind, straight onto the attached socket, outside the window. Binds are routed there by `request()` so `send()` still carries a hand-wired bind. | ++| `sms.ts` | `sendResp()` marks the message answered synchronously before writing, so a second call and a failed handler both find it; a failed write leaves the id alone. `SmsHandlers` is `answer`/`answered`/`lostLink`/`send`, all explicit. | ++| `bind-direction.ts` | Bind types, commands (derived from one list), `LinkEnd`, `standsInFor`, `bindCarries`, `checkedBind`; out of `session-options.ts`, which keeps options and their checks. | ++| `defaults.ts` | Every default, one object; `client.ts`, `server.ts`, `reassembly.ts`, `reconnect-loop.ts` read it. | ++ ++The other modules are unchanged in shape; `DlrMerger.close()` is `spend()`. ++ ++## Deleted ++ ++`held-messages.ts` (`HeldMessages`, `MessageHold`, the `WeakMap`, the `setImmediate`, the listener ++count, the re-used-sequence-number exit); `Session[captureRejectionSymbol]`'s routing into the hold; ++`IncomingRequests.listenerRejected()` and its `refusing` flag; `OutgoingRequests.requestPastDrain()` ++and `requestOnCurrentLink()` (folded into `request()` + `requestOnLink()`); `Session.end()`, ++`stop()`, `emitClose()`, `teardown()`, `onClose()`, `attach()`, `resetTimers()`, `answering()` and ++the `shutdownTimeout: 0` fallback; three `defaults` objects, `backoffDefaults`, `defaultMaxOctets`, ++`defaultSystemId`'s re-export; `checkHooks()` in `server.ts` (now in `checkSessionOptions()`); the ++`sms` key of `SessionEvents`; `EncodingName` `'ASCII'`. Also fixed on the way: a rebind answered on ++a link the peer had dropped no longer opens the dead socket (todo item, now in `comeBackUp()`). ++ ++## Tests ++ ++`docker compose run --rm node npm test`: lint and typecheck clean, **520 tests, 520 pass, 0 fail** ++(baseline 515). Every `session.on('sms', …)` in the suite became an `onSms` option (a test helper ++`inbox()` hands the first message to the test and holds nothing; `holding()` and `stuck` hold). None ++weakened on the wire or on goal 2. Changed beyond that mechanical move: ++ ++- `graceful shutdown`: `submitInFlight()` holds through the handler and returns `release()`. Replaced ++ `gives up on a message the application never answers` → `…a handler that never returns`; ++ `falls back to responseTimeout …` → `waits out a handler for as long as it takes at shutdownTimeout 0` ++ (the fallback is gone); `a listener that rejected before answering …` → `a handler that rejected before ++ answering has the message refused for it, and holds nothing` (asserts the peer's `ESME_RTHROTTLED`); ++ `waits for the listener still working when another one rejected` (no second listener exists) → three ++ tests: `close() keeps waiting for a handler that answered and is still working`, `close() stops waiting ++ once the handler returns, answered or not`, `a handler that rejected after answering leaves that answer alone`; ++ `a message no listener took …` → `… no handler takes is refused at once …`; `leaves a message the library ++ refused to answer unanswered` → `keeps waiting on a handler whose answer the library refused`; ++ tests that relied on "no listener" to keep a request in flight use a `stuck` handler. Messages read ++ `still being handled` where they read `unanswered`. ++- `held message bounds` → `handled message bounds`: same bounds, exits are now handler return, sweep, ++ clear; added `answers for a handler that failed before answering, and reports it` and `answers for a ++ message no handler takes`. ++- `sendResp()`: added `answers once, and keeps the id it was given only once that answer is on the wire`. ++- `refuses to answer a message whose link went, held or already answered`: the already-answered ++ message is now refused as `already answered` (the held one still as link gone). ++- `turns a throwing sms listener …` and siblings, and `session-error.test.ts`: listener → handler. ++- `encodings`/`message`/`unsendable`/`message-class`/`declared-alphabet`: `'ASCII'` → `'GSM'`. ++- `readme.test.ts` follows the README examples; `incomingOn()` passes `answer` and `sendReceipt`. +diff --git a/MIGRATION-NOTES.md b/MIGRATION-NOTES.md +new file mode 100644 +index 0000000..3c2968a +--- /dev/null ++++ b/MIGRATION-NOTES.md +@@ -0,0 +1,29 @@ ++# Breaking changes for a 0.5.0 user ++ ++Each line names the change and its one-line replacement. ++ ++- **The `sms` event is gone; inbound messages go to an `onSms` handler option.** ++ `session.on('sms', async sms => { … })` → `client({ onSms: async sms => { … } })`. ++- **A server's handler is a server option, not a per-session listener.** ++ `smpp.on('session', s => s.on('sms', h))` → `server({ onSms: h })`; `sms.session` is the session. ++- **A hand-wired `Session` takes the handler the same way.** ++ `session.on('sms', h)` → `new Session({ onSms: h, sock })`. ++- **The hold on a message is the handler's promise, not `sendResp()`.** `close()` waits until your ++ handler returns; do the answer and the receipt inside it. A handler that returns before answering ++ releases the hold; the message then waits on you, not on the shutdown. ++- **A handler that throws or rejects before answering has the message refused for it** ++ (`ESME_RTHROTTLED` on a submission, `ESME_RX_T_APPN` on a delivery), so the peer retries. Catch ++ what you want answered otherwise. Rejected listeners used to leave the message unanswered. ++- **No `onSms` means every inbound message is refused that way and reported on `sessionError`.** A ++ transceiver that only sends sees `dlr` events as before; give it `onSms` only if it receives. ++- **`sendDlr()` no longer needs to follow `sendResp()` in the same turn to pass a shutdown.** Send it ++ wherever in the handler you like; nothing to replace. ++- **`shutdownTimeout: 0` waits for a handler as long as it takes**, where it used to give the ++ messages `responseTimeout`. Set `shutdownTimeout` if you want a bound. ++- **`sendResp()` twice on one message is refused** with `This message was already answered`. ++ Answer once. ++- **`sendResp()` that fails leaves `sms.smsId` unchanged.** Read `sms.smsId` after a successful ++ answer, not before. ++- **`encoding: 'ASCII'` is `encoding: 'GSM'`**, and so are `encodings.GSM`, `dataCodingByEncoding.GSM` ++ and what `detect()` and `encodingByDataCoding()` return. `'ASCII'` is refused by name. ++- **`SessionEvents` has no `sms` key**; a listener typed against it moves to `OnSms`. +diff --git a/MIGRATION.md b/MIGRATION.md +index 0296ede..29f9d76 100644 +--- a/MIGRATION.md ++++ b/MIGRATION.md +@@ -11,6 +11,8 @@ shape is the same, connect, send, listen for delivery reports, with callbacks re + `close()`, or the socket outlives the call. + - **`server()` resolves once, when it is listening**, with a handle carrying `close()`, `port` and + a `session` event. It no longer calls back once per connection. ++- **The `sms` event is the `onSms` option**, on `client()` and `server()` alike: ++ `onSms: async sms => { await sms.sendResp(); }`. The message is held until the handler returns. + - **The id a message is answered with goes to `sendResp({ smsId })`.** `sms.smsId` is read-only: the + id the segments were answered with, the id `sendResp()` was given, or the generated UUID v7. + Assigning to it throws a `TypeError` in strict-mode code (every ES module, and any file under +@@ -85,7 +87,7 @@ have for these: + directions. 0.4.0 kept only the last one it read. + - A body carried in the `message_payload` TLV was ignored, so the message arrived empty, and a + `data_sm` was answered `ESME_RINVCMDID`, so a receipt thrown on one was lost silently. Both reach +- the application now: a receipt as `dlr`, answered for you, and a message as `sms` for you to answer. ++ the application now: a receipt as `dlr`, answered for you, and a message to `onSms` for you to answer. + - A long message segmented by the `sar_msg_ref_num`, `sar_total_segments` and `sar_segment_seqnum` + TLVs rather than a user data header was never reassembled, so each segment arrived as its own + message. Both spellings reassemble now. +diff --git a/README.md b/README.md +index 9ff2b7d..2cbbe73 100644 +--- a/README.md ++++ b/README.md +@@ -10,7 +10,8 @@ window, long messages and delivery receipts. TypeScript, ESM, no dependencies. + - **Send window.** 10 requests in flight; further sends queue instead of overrunning the SMSC. + - **Long messages.** Split on send, reassembled on receive, in both the UDH and `sar_*` spellings. + - **Delivery receipts.** Read from TLVs or from receipt text, matched to the ids you were given. +-- **Graceful shutdown.** `close()` waits for what is in flight, so neither end has to guess. ++- **Graceful shutdown.** `close()` waits for your message handlers and for what is in flight, so ++ neither end has to guess. + - **Never throws.** Every fallible call resolves to `{ err?, … }`. + - **Interoperable.** Tested as a client against Jasmin and SMPPSim, and as a server against Kannel, + jsmpp, Cloudhopper, python-smpplib and php-smpp: +@@ -92,34 +93,37 @@ receipts into one, and SMSCs that write ids in two notations: [Delivery receipts + + ## Receive SMS + +-A `receiver` or `transceiver` client gets mobile-originated messages as `sms` events: ++A `receiver` or `transceiver` client hands mobile-originated messages to its `onSms` handler: + + ```javascript +-session.on('sms', async sms => { +- // sms.from, sms.to, sms.message +- await sms.sendResp(); ++const { err, session } = await client({ ++ onSms: async sms => { ++ // sms.from, sms.to, sms.message ++ await sms.sendResp(); ++ }, + }); + ``` + +-Call `sendResp()` for every message, multipart included: until you do, it counts toward the bound +-past which the peer's messages are refused. Delivery receipts reach you as `dlr` events, not here. A +-multipart message arrives reassembled and already answered segment by segment, so `sendResp()` there +-puts nothing on the wire and releases it: [Receiving in depth](#receiving-in-depth). ++Call `sendResp()` once for every message, multipart included; a message you never answer is one ++the peer waits on. The message is held for as long as your handler runs: it counts toward the bound ++past which the peer's messages are refused, and `close()` waits for the handler to return. A handler ++that throws or rejects before answering has the message refused for it, so the peer retries; without ++an `onSms` at all, every message is refused that way. Delivery receipts reach you as `dlr` events, ++not here. A multipart message arrives reassembled and already answered segment by segment, so ++`sendResp()` there puts nothing on the wire: [Receiving in depth](#receiving-in-depth). + + ## Run an SMPP server + + ```javascript + import { server } from '@larvit/smpp'; + +-const { err, server: smpp } = await server(); +-if (err) throw err; +- +-smpp.on('session', session => { +- session.on('sms', async sms => { +- // sms.from, sms.to, sms.message, sms.dlr ++const { err, server: smpp } = await server({ ++ onSms: async sms => { ++ // sms.from, sms.to, sms.message, sms.dlr, sms.session + await sms.sendResp(); +- }); ++ }, + }); ++if (err) throw err; + ``` + + With authentication and delivery reports: +@@ -134,13 +138,10 @@ const { err, server: smpp } = await server({ + + return { userData: { userId: 123 } }; + }, +-}); +-if (err) throw err; +- +-smpp.on('session', session => { +- session.on('sms', async sms => { ++ onSms: async sms => { ++ // sms.session.userData is what authenticate returned for this peer + if (sms.answeredOnArrival) { +- await sms.sendResp(); // multipart: already answered per segment; this only releases the shutdown drain ++ await sms.sendResp(); // multipart: already answered per segment, so this writes nothing + } else { + // no args: ESME_ROK + generated id; or sendResp({ smsId, status: 'ESME_RMSGQFUL' }) + await sms.sendResp(); +@@ -149,19 +150,22 @@ smpp.on('session', session => { + if (sms.dlr) { + await sms.sendDlr(); // same as sms.sendDlr('DELIVERED') + } +- }); ++ }, + }); ++if (err) throw err; + + console.log(smpp.port); // the port actually bound, useful when 0 was requested + await smpp.close(); // stop listening, then drain and close every live session + ``` + + - `sendResp()` answers `ESME_ROK` with a generated UUID v7 as the message id. +- `sendResp({ smsId, status })` names the id or refuses the message. ++ `sendResp({ smsId, status })` names the id or refuses the message. A second call is refused. + - `sms.dlr` is true where the sender asked for a receipt. `sendDlr()` reports `DELIVERED`, + `sendDlr('UNDELIVERABLE')` any other state: [Server in depth](#server-in-depth). + - A message that arrived in several segments was answered as they arrived, so `sendResp()` there + takes no `smsId` or refusing `status`. `sms.answeredOnArrival` says which case you are in. ++- `smpp.close()` waits for every running `onSms` before it closes a session, so answer and send the ++ receipt inside the handler, as above, and nothing is cut off. + + ## Errors + +@@ -183,7 +187,7 @@ Neither is named `error`, because Node throws on an unhandled `error` event. + | --- | --- | --- | + | A PDU the peer sent that the codec could not read. The link stays up; only that PDU is lost. | `PduRefusedError` | Count it. | + | A concatenated message given up on before it was whole. Its segments were answered, so the peer will not resend, and no `sms` fired for it. | `Error` | Count it as lost traffic. | +-| The session or socket failing, or a hook or listener that threw or rejected. | `Error` | Alert. | ++| The session or socket failing, or a hook, handler or listener that threw or rejected. | `Error` | Alert. | + + The last two are told apart by message text only, so this alerts on both: + +@@ -231,8 +235,9 @@ All optional. Timeouts and delays are milliseconds. + | `enquireLinkInterval` | `20000` | Interval between `enquire_link` on a quiet link. | + | `idleTimeout` | `2 × enquireLinkInterval` | Give up on a link the peer has stopped answering, and re-bind unless `reconnect` is `false`. | + | `responseTimeout` | `30000` | How long to wait for a response, and how long a send with no link waits for the next one. `0` waits forever. | +-| `shutdownTimeout` | `5000` | How long `close()` and `unbind()` wait for requests already sent and messages not yet answered. `0` waits forever for the requests, which end when the peer answers or `responseTimeout` expires, so both at `0` never ends. The messages then fall back to `responseTimeout`, or to its default where that is `0` too. | ++| `shutdownTimeout` | `5000` | How long `close()` and `unbind()` wait for `onSms` handlers still running and for requests already sent. `0` waits forever: a request ends when the peer answers or `responseTimeout` expires, so both at `0` never ends, and a handler ends when it returns or after the five minutes past which one is no longer counted. | + | `maxOutstanding` | `10` | Requests on the wire at once; further sends queue. | ++| `onSms` | refuse | `(sms) => Promise \| void`. Takes every inbound message: [Receive SMS](#receive-sms). | + | `smsIdFormat` | — | The notation the SMSC writes message ids in, per place: `{ receipt: 'decimal', submitResp: 'hex' }`. Only where the two disagree: [Delivery receipts](#delivery-receipts). | + | `reconnect` | on | `{ minDelay, maxDelay }` retunes the backoff; `false` turns it off, so a drop ends the session; `{ fromStart: true }` retries the first connect too. | + | `log` | silent | Any object with `debug`, `error`, `info`, `verbose` and `warn` methods: [Logging](#logging). | +@@ -257,6 +262,7 @@ All optional. Timeouts are milliseconds. + | `host`, `port` | all interfaces, `2775` | Where to listen. `port: 0` takes any free port; `smpp.port` says which. | + | `authenticate` | accept everything | `({ password, session, systemId, systemType }) => false \| { userData }`, sync or async. | + | `onRequest` | none | `(session, pduObj) => true \| false`, sync or async. First refusal on every request a bound peer sends: [Server in depth](#server-in-depth). | ++| `onSms` | refuse | `(sms) => Promise \| void`. Takes every message a bound peer submits, on every session; `sms.session` says which: [Run an SMPP server](#run-an-smpp-server). | + | `systemId` | `''` | The SMSC identity returned in the bind response. | + | `interfaceVersion` | `0x34` | The SMPP version advertised in the bind response. Optional parameters are sent to a peer from `0x34` up, whatever this is set to. | + | `tls` | `false` | A `tls.TlsOptions` object with your certificate and key. A bare `true` is refused. | +@@ -298,11 +304,11 @@ refuse a non-ASCII sender of its own accord, which reaches you as a refusal such + + | `encoding` | Alphabet | Characters per SMS | Per segment of a long message | + | --- | --- | --- | --- | +-| `ASCII` | GSM 03.38 7-bit | 160 | 153 | ++| `GSM` | GSM 03.38 7-bit | 160 | 153 | + | `LATIN1` | ISO 8859-1 | 140 | 134 | + | `UCS2` | UCS-2 | 70 | 67 | + +-- Omitted: `ASCII` where the message fits GSM 7-bit, otherwise `UCS2`. `LATIN1` only when named. ++- Omitted: `GSM` where the message fits GSM 7-bit, otherwise `UCS2`. `LATIN1` only when named. + Any other name is refused. + - GSM extension characters (`{}[]\~^|€` and form feed) count as two, as does a character outside + the basic multilingual plane in `UCS2`. +@@ -357,9 +363,11 @@ holds for `session.send()`. + + ### Events + ++A request you must answer is a handler option: `onSms`, and `onRequest` on a server. A fact you may ++watch is an event, and nothing waits on its listeners. ++ + | Event | Fires when | + | --- | --- | +-| `sms` | An SMS arrives, reassembled if it was multipart. Carries `sendResp()`, `sendDlr()` and `smsId`. | + | `dlr` | A delivery report arrives, one per segment, with its PDU as the second argument: [Delivery receipts](#delivery-receipts). | + | `messageDlr` | Every segment of a long message sent with `dlr: true` has a final report: [Delivery receipts](#delivery-receipts). | + | `close` | The session is over and nothing will bring the link back. Fires once, whether you closed it or the link failed for good. | +@@ -376,17 +384,14 @@ holds for `session.send()`. + + **Shutdown.** `close()` and `unbind()` both: + +-1. Refuse further sends. `sendDlr()` is the one send let past, when issued straight after +- `sendResp()`; await anything in between and it races the shutdown like any other send. +-2. Wait up to `shutdownTimeout` for the requests already sent, and for every `sms` the application +- has not answered. That wait ends when `sendResp()` puts the response on the wire (or, for a +- message answered on arrival, when it is called at all), or when every listener that took the +- message has failed. Answering through `sendReturn()` instead leaves the wait running. ++1. Refuse further `sendSms()` and `send()`. `sendResp()` and `sendDlr()` still go out, because they ++ finish a message the shutdown is waiting on. ++2. Wait up to `shutdownTimeout` for every `onSms` handler still running, then for the requests ++ already sent. + 3. Tear down what is left, resolving to an `err` that says what was lost. + +-A message left unanswered for five minutes is no longer waited for. +-`close({ signal })` cuts the wait short. `unbind()` takes no signal, and waits a further +-`responseTimeout` for its own response. ++A handler still running after five minutes is no longer waited for. `close({ signal })` cuts the wait ++short. `unbind()` takes no signal, and waits a further `responseTimeout` for its own response. + + **Sends and the link.** + +@@ -442,12 +447,15 @@ const { err, pduObj } = await session.send({ + each is two messages. + - **Answered on arrival.** Each segment was answered as it landed, before you see the message: + [Server in depth](#server-in-depth). +-- **Unanswered messages.** While a session holds 1000 messages you have not called `sendResp()` on, +- or 64 MiB of them counted the way `maxOctets` counts segments, every new message and segment is +- refused so the peer retries it: `ESME_RTHROTTLED` on a submission, `ESME_RX_T_APPN` on a delivery. +- No `sms` fires. Reaching the bound logs one `warn`, and the first message accepted once both are +- down to half one `info`. A message left five minutes is dropped from the count with a `warn`; a +- later `sendResp()` still answers it. None of the three is an option. ++- **Messages being handled.** While 1000 `onSms` handlers are running on a session, or the messages ++ they hold weigh 64 MiB counted the way `maxOctets` counts segments, every new message and segment ++ is refused so the peer retries it: `ESME_RTHROTTLED` on a submission, `ESME_RX_T_APPN` on a ++ delivery. The handler is not called. Reaching the bound logs one `warn`, and the first message ++ accepted once both are down to half one `info`. A handler running five minutes is dropped from the ++ count with a `warn`; its `sendResp()` still answers. None of the three is an option. ++- **A handler that fails.** One that throws or rejects reaches `sessionError`. Where it had not ++ answered, the message is refused with the same retry status, so the peer sends it again; where it ++ had, that answer stands. The same happens to every message where no `onSms` was given. + - **Where the body is.** A body in the `message_payload` TLV, SMPP's way of carrying up to 64 KB and + the only place a `data_sm` has, reads exactly like one in `short_message`, concatenated messages + and receipts included. A PDU filling both is read from `short_message`. +@@ -517,7 +525,7 @@ cannot, since a peer may number a message one part of one. + + **Refusing a request** for a reason in the request rather than the message (a full queue, an unknown + recipient, an unauthorised sender) has to land before a segment is answered. `onRequest` runs on +-every request a bound peer sends, before reassembly and before the `sms` event: ++every request a bound peer sends, before reassembly and before `onSms`: + + ```javascript + import { isCommand, server } from '@larvit/smpp'; +@@ -668,7 +676,7 @@ if (isCommand(pduObj, 'submit_sm')) { + | Receipts | `dlrFromPdu`, `parseReceipt`, `receiptCodes` | + | Time and ids | `smppDate`, `smppTime`, `uuidv7` | + | Spec tables | `cmds`, `consts`, `encodings`, `errors`, `tlvs`, `types`, the `cmdsById`, `constsById`, `errorsById` and `tlvsById` maps, and all of them grouped as `defs`. `isCommandName`, `isErrorName`, `isEncodingName`, `isTlvName`, `commandNameById` and `errorNameById` narrow a value into them. | +-| Types | Every option, result, event payload and table entry has a named type: `ClientOptions`, `ServerOptions`, `SendSmsOptions`, `SendSmsResult`, `Sms`, `Dlr`, `MessageDlr`, `Receipt`, `PduObject`, `PduHeader`, `SmppLog`, `Result` and the rest in `dist/index.d.ts`. | ++| Types | Every option, hook, result, event payload and table entry has a named type: `ClientOptions`, `ServerOptions`, `OnSms`, `SendSmsOptions`, `SendSmsResult`, `Sms`, `Dlr`, `MessageDlr`, `Receipt`, `PduObject`, `PduHeader`, `SmppLog`, `Result` and the rest in `dist/index.d.ts`. | + + ## What changed per release + +diff --git a/benchmarks/smsc-sink.ts b/benchmarks/smsc-sink.ts +index 324c11e..5779ba0 100644 +--- a/benchmarks/smsc-sink.ts ++++ b/benchmarks/smsc-sink.ts +@@ -5,22 +5,20 @@ import { server } from '../src/server.ts'; + * library's own ceiling rather than an SMSC's storage. Prints the bound port on stdout, then waits. + */ + const port = Number(process.env.PORT ?? 0); +-const { err, server: smpp } = await server({ port }); ++let answered = 0; ++const { err, server: smpp } = await server({ ++ onSms: async sms => { ++ answered++; ++ await sms.sendResp(); ++ }, ++ port, ++}); + + if (err) { + process.stderr.write(`sink failed to listen: ${err.message}\n`); + process.exit(1); + } + +-let answered = 0; +- +-smpp.on('session', session => { +- session.on('sms', async sms => { +- answered++; +- await sms.sendResp(); +- }); +-}); +- + smpp.on('serverError', reason => { + process.stderr.write(`sink serverError: ${reason.message}\n`); + }); +diff --git a/docs/decisions.md b/docs/decisions.md +index c245097..c34cb05 100644 +--- a/docs/decisions.md ++++ b/docs/decisions.md +@@ -6,6 +6,24 @@ rule and an index of the titles below. + + ## The public surface + ++- **A request the application must answer is a handler option; a fact it may watch is an event.** ++ Maintainer's call, 2026-09-29, from the comprehension panel's round two: every seat ranked the ++ `sms` event's hold hardest — released one turn after `sendResp()`, or when every listener had ++ rejected against a count taken at emit, or when no listener took it, or on a re-used sequence ++ number — and the internals could not be made simpler than the contract demanded. `onSms` on ++ `client()`, `server()` and `Session` takes each message, and the returned promise is the hold: ++ the message counts toward the bound and `close()` waits, until the handler settles. Goal 2 settles ++ the failure case: a handler that throws before answering has decided nothing, so the message is ++ refused with the retry status and the peer sends it again; one that threw after answering keeps ++ its answer, since a second response is goal 1's wire violation. No `onSms` at all takes the same ++ path, per message, so a receiver bound with nothing to receive into is visible rather than silent. ++ `dlr`, `messageDlr` and the life events stay events, since nothing waits on their listeners. ++ Rejected: keeping the event and waiting on the listener's own promise, which needs `listeners()` ++ re-declared and a count of them at emit — the contract that made the hold six exits. Rejected: a ++ handler that returns the answer for the library to write, which cannot express answering first and ++ working after, nor a receipt that must follow the answer inside the same hold. Rejected: a ++ settable `session.onSms`, a second spelling of the option. ++ + - **`Session` is publicly constructible, which is what makes `SessionOptions` and `ReconnectOptions` + public too.** Raised twice as a leak; it is not one. The collaborators `session.ts` delegates to + stay unpublished so they can be reshaped. +@@ -17,17 +35,16 @@ rule and an index of the titles below. + three the library dispatches by it, of which it sends the first two. + + - **Both emitters re-declare their listener methods to accept a promise.** Maintainer's call, +- 2026-08-27: `EventEmitter` types every listener as void-returning, so the +- `session.on('sms', async sms => …)` README documents reads as a misused promise in any strict +- consumer. `declare on: …` and its six siblings re-type the inherited methods to return `unknown`, ++ 2026-08-27: `EventEmitter` types every listener as void-returning, so an ++ `session.on('dlr', async dlr => …)` reads as a misused promise in any strict consumer. `declare on: …` and its six siblings re-type the inherited methods to return `unknown`, + which emits nothing and needs no cast; overriding them as real methods cannot work, because the + `super.on()` call needs one. The cost is that a subclass can no longer reach those seven through + `super` — re-declaring them the same way is its way out. `unknown` rather than + `void | Promise` because a listener may return anything: `session.on('close', () => +- set.delete(session))` returns a boolean. This also settles what the drain can wait on: a listener's +- own promise would be the better completion signal, and reaching it needs `listeners()`, which +- cannot be re-declared the same way — Node types it invariantly enough that widening `void` to +- `unknown` is `TS2416`. Re-probed 2026-09-01; `sendResp()` stays the signal. ++ set.delete(session))` returns a boolean. A listener's own promise cannot be waited on, since ++ reaching it needs `listeners()`, which cannot be re-declared the same way — Node types it ++ invariantly enough that widening `void` to `unknown` is `TS2416` — which is why a message goes to ++ a handler option rather than an event. + + - **`PduRefusedError` is exported, and `sessionError` names it in the event's type.** Maintainer's + call, 2026-09-05, from a product review: one event carries both a PDU the peer malformed and the +@@ -225,8 +242,8 @@ rule and an index of the titles below. + carried where bit 4 says so in every group below 0x80 and always in the 0xF0 group, and + `encodingByDataCoding()` reads the alphabet off that same test rather than repeating the group + masks beside it. It is exported for the reason `concatOf()` is — an application that needs a class +- other than 0 would otherwise rewrite the read this fixed. Rejected: a `messageClass` field on the +- `sms` event, which pays goal 8 for three classes nothing here acts on, where the boolean the ++ other than 0 would otherwise rewrite the read this fixed. Rejected: a `messageClass` field on ++ `Sms`, which pays goal 8 for three classes nothing here acts on, where the boolean the + application already had covers the one it does. Compressed text is out of scope and stays out — + nothing here implements 3GPP TS 23.042, so a compressed body reaches the application as whatever + its declared alphabet makes of it — but bit 5 does not move the class bits, so 0x30 is read as +@@ -583,8 +600,8 @@ rule and an index of the titles below. + one round trip rather than one per segment. Rejected: sending each segment once the last is + answered, which a receiver waiting for the whole message before answering would deadlock. + +-- **Every segment of a concatenated message is answered as it arrives, so `sendResp()` on one is the +- application's own signal rather than the peer's answer.** Maintainer's call, 2026-09-06, from the ++- **Every segment of a concatenated message is answered as it arrives, so `sendResp()` on one writes ++ nothing.** Maintainer's call, 2026-09-06, from the + Jasmin interoperability phase: Jasmin dispatches one `submit_sm` per connector at a time and will + not send segment 2 until segment 1 is answered, so holding a group unanswered until it was whole + deadlocked every multi-segment message against a production gateway +@@ -596,7 +613,7 @@ rule and an index of the titles below. + no-op. `answeredOnArrival` is on `Sms` because nothing the application can compute says it, and the + discriminant a reader would reach for instead is wrong. A message `sendResp()` still answers itself is + untouched, and is where a caller-chosen id and a refusal live; `onRequest` is the escape hatch for +- an application that must refuse a PDU the `sms` event could not have shown it yet. `collect()` ++ an application that must refuse a PDU `onSms` could not have shown it yet. `collect()` + answers every segment it will not carry rather than leaving it unanswered, which is the same stall + in miniature: the field that numbered it where the segment belongs to no group, the retry status + where the segment's own arrival overran the octet cap, since a peer told that still holds it. Rejected: +@@ -613,7 +630,7 @@ rule and an index of the titles below. + distinguishable. Accepted: a completing segment whose own answer the socket would not carry still + reaches the application, because the message is whole and correct and the failed answer is on + `sessionError` — a peer that re-sends after the drop is the smaller risk than dropping a message +- in hand. The answer goes out before the `sms` event either way, so a listener's own receipt can ++ in hand. The answer goes out before `onSms` is called either way, so a handler's own receipt can + never precede the acceptance of the message it reports on. + + - **`server()` composes the application's `onRequest` after its own bind handling, and offers it +@@ -653,36 +670,39 @@ rule and an index of the titles below. + fall-through could be gated on it, which buys a fail-open path with state and an internal contract + no other collaborator needs. + +-- **The drain waits on the messages the application holds, and `sendResp()` is what says it is done +- with one.** Maintainer's call, 2026-09-01: waiting on the send window alone tore a server session +- down while the application was still answering a `submit_sm`, so the peer timed out and re-sent — +- the duplicate goal 2 forbids, in the direction the window already covers. No completion signal was +- added to the `sms` event: `sendResp()` is what an application already calls when it is done with a +- message, so it is the one the drain waits for. Counting every inbound request until `sendReturn()` +- answered it was rejected: an `onRequest` that deliberately answers nothing would then cost a full +- `shutdownTimeout` on every close. The response reaching the wire ends the wait, so a `sendResp()` +- the library refused or the socket would not carry leaves `close()` still reporting the message the +- peer is owed. +- +-- **The drain's wait on the application ignores `shutdownTimeout: 0`.** Waiting forever is safe for +- the peer, whose every request is bounded by `responseTimeout` unless the caller set that to 0 as +- well, and unsafe for the application, which nothing bounds — `close()` is what you reach for when +- the 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 +- 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 +- refused.** A bound the application cannot raise is the point: an application that answers nothing +- would otherwise hold ever more messages, which goal 4 forbids. Reassembly's `maxOctets` is an +- option because it bounds what the peer sends; this bounds what the application leaves unanswered. ++- **The drain waits on the `onSms` handlers still running, and a handler's promise is what says it ++ is done with a message.** Maintainer's call, 2026-09-01, re-settled 2026-09-29 with the handler ++ option: waiting on the send window alone tore a server session down while the application was ++ still answering a `submit_sm`, so the peer timed out and re-sent — the duplicate goal 2 forbids. ++ The handler returning is the one signal, so a receipt sent inside it goes out before the drain ++ ends and `shutdownTimeout: 0` waits for it as it waits for a request, bounded by the five-minute ++ deadline past which a handler is no longer counted. Rejected: `sendResp()` as the signal, which ++ had to let a receipt sent one turn later past the drain and could not see a handler still working. ++ Rejected: counting every inbound request until `sendReturn()` answered it — an `onRequest` that ++ deliberately answers nothing would then cost a full `shutdownTimeout` on every close. Rejected: a ++ `responseTimeout` fallback for a `shutdownTimeout` of 0, a second bound where the deadline already ++ is one. ++ ++- **A handler that fails before answering has the message refused for it; one that fails after has ++ its answer stand.** Goal 2: a handler that threw decided nothing, so the peer keeps the message and ++ retries on `ESME_RTHROTTLED` or `ESME_RX_T_APPN`, the same answer the bound gives; after an answer ++ nothing more is written, since a second response is goal 1's wire violation. `sendResp()` marks ++ the message answered before its write and refuses a second call, which is what makes the two ++ cases separable. Rejected: leaving the message unanswered, which is what the `sms` event did and ++ what stalls a peer that dispatches one request at a time. ++ ++- **What the running handlers hold is capped on constants, and a message past the cap is ++ refused.** A bound the application cannot raise is the point: a handler that never returns would ++ otherwise hold ever more messages, which goal 4 forbids. Reassembly's `maxOctets` is an ++ option because it bounds what the peer sends; this bounds what the application is still handling. + Maintainer's call, 2026-09-26. Refusing leaves the message with the peer, which will send it again +- (goal 2). Rejected: dropping the oldest to make room, which frees nothing while the application +- still holds its `Sms`, and stops the drain waiting for a message the peer is owed. Rejected: ++ (goal 2). Rejected: dropping the oldest to make room, which frees nothing while the handler still ++ holds its `Sms`, and stops the drain waiting for a message the peer is owed. Rejected: + pausing the socket, which also stalls every answer and `enquire_link` on the link. Reaching the + bound shows only in the log (goal 8): an event or a public count would be surface for what the +- application already knows, since it is the one not answering. A message held past its timeout is +- still dropped, so `close()` can report fewer unanswered than there were — accepted, because the +- alternative is holding what nothing will answer. ++ application already knows, since it is the one not returning. A handler past its timeout is still ++ dropped from the count, so `close()` can report fewer than there were — accepted, because the ++ alternative is holding what nothing will finish. + + - **A store at its bound answers `ESME_RTHROTTLED` to a submission and `ESME_RX_T_APPN` to a + delivery, a `data_sm` by whichever it stands in for.** Maintainer's call, 2026-09-26, for +diff --git a/interop-tests/cloudhopper.test.ts b/interop-tests/cloudhopper.test.ts +index dca5b5b..2ddda53 100644 +--- a/interop-tests/cloudhopper.test.ts ++++ b/interop-tests/cloudhopper.test.ts +@@ -41,23 +41,20 @@ async function driver(path: string, params: Record = {}): Promis + const manualTexts = new Set(); + const allSms: { session: Session; sms: Sms }[] = []; + +-function attach(session: Session): void { +- session.on('sms', sms => { +- allSms.push({ session, sms }); ++function onSms(sms: Sms): void { ++ allSms.push({ session: sms.session, sms }); + +- if (manualTexts.has(sms.message)) return; ++ if (manualTexts.has(sms.message)) return; + +- // The slow server this phase's window scenarios need: every ordinary submit is held for +- // SLOW_DELAY_MS before being answered, so a burst genuinely presses on a small window. +- void delay(SLOW_DELAY_MS).then(() => sms.sendResp()); +- }); ++ // The slow server this phase's window scenarios need: every ordinary submit is held for ++ // SLOW_DELAY_MS before being answered, so a burst genuinely presses on a small window. ++ void delay(SLOW_DELAY_MS).then(() => sms.sendResp()); + } + +-const { err: serverErr, server: smpp } = await server({ authenticate: () => true, idleTimeout: 40_000, port: SMPP_PORT }); ++const { err: serverErr, server: smpp } = await server({ authenticate: () => true, idleTimeout: 40_000, onSms, port: SMPP_PORT }); + + assert.equal(serverErr, undefined); + assert.ok(smpp); +-smpp.on('session', attach); + + const key = readFileSync('/shared-certs/server.key'); + const cert = readFileSync('/shared-certs/server.crt'); +@@ -67,13 +64,13 @@ const cert = readFileSync('/shared-certs/server.crt'); + const { err: tlsServerErr, server: tlsSmpp } = await server({ + authenticate: () => true, + idleTimeout: 40_000, ++ onSms, + port: TLS_PORT, + tls: { cert, key, maxVersion: 'TLSv1.2' }, + }); + + assert.equal(tlsServerErr, undefined); + assert.ok(tlsSmpp); +-tlsSmpp.on('session', attach); + + after(async () => { + await smpp.close(); +diff --git a/interop-tests/dumbclient.test.ts b/interop-tests/dumbclient.test.ts +index 81bc9ff..61b5d80 100644 +--- a/interop-tests/dumbclient.test.ts ++++ b/interop-tests/dumbclient.test.ts +@@ -105,6 +105,23 @@ const { err, server: smpp } = await server({ + authenticate: ({ systemId }) => ({ userData: { systemId } satisfies ScenarioUserData }), + idleTimeout: 40_000, + log, ++ onSms: sms => { ++ const { session } = sms; ++ const name = scenarioOf(session); ++ ++ sessionByScenario.set(name, session); ++ ++ const s = statsFor(name); ++ const arrivalIndex = s.arrived; ++ ++ s.arrived++; ++ if (s.ids.has(sms.smsId)) s.duplicateIds++; ++ else s.ids.add(sms.smsId); ++ s.peakOutstanding = Math.max(s.peakOutstanding, s.arrived - s.answered); ++ ++ if (name === 'dumb-w500' || name === 'dumb-w2000') slowRespond(session, sms, arrivalIndex); ++ else fastRespond(session, sms, arrivalIndex); ++ }, + port: SMPP_PORT, + }); + +@@ -141,23 +158,6 @@ function fastRespond(session: Session, sms: Sms, arrivalIndex: number): void { + smpp.on('session', session => { + // Attached now, not lazily in a test body - see the comment on ScenarioStats.closed. + session.on('close', () => { statsFor(scenarioOf(session)).closed = true; }); +- +- session.on('sms', sms => { +- const name = scenarioOf(session); +- +- sessionByScenario.set(name, session); +- +- const s = statsFor(name); +- const arrivalIndex = s.arrived; +- +- s.arrived++; +- if (s.ids.has(sms.smsId)) s.duplicateIds++; +- else s.ids.add(sms.smsId); +- s.peakOutstanding = Math.max(s.peakOutstanding, s.arrived - s.answered); +- +- if (name === 'dumb-w500' || name === 'dumb-w2000') slowRespond(session, sms, arrivalIndex); +- else fastRespond(session, sms, arrivalIndex); +- }); + }); + + function memShape(): string { +@@ -206,7 +206,7 @@ after(async () => { + + // S9 (target 11) and the backpressure-at-server scenario: window 2000 at a high rate against a + // handler slowed enough to build a real backlog. window500 is the same shape with a window below +-// maxHeldMessages (1000, session-options.ts defaults.maxHeldMessages), the bound past which a ++// maxHandledMessages (1000, defaults.ts), the bound past which a + // peer's window is answered ESME_RTHROTTLED. smpp-dumb-client counts a throttled message as sent + // and never resends it, so window 2000 accounts for 20,000 as answered plus throttled. + const throttleMessage = 'session - unanswered messages at their bound, asking the peer to retry'; +@@ -236,7 +236,7 @@ describe('S9 - bounded window against a slowed handler', () => { + }); + } + +- test('window 2000 pressed past maxHeldMessages (1000): the peer is throttled, window500 never is', () => { ++ test('window 2000 pressed past maxHandledMessages (1000): the peer is throttled, window500 never is', () => { + assert.ok(throttled('dumb-w2000') > 0, 'expected at least one ESME_RTHROTTLED under window 2000'); + assert.ok(statsFor('dumb-w2000').peakOutstanding <= 1000); + assert.equal(statsFor('dumb-w500').peakOutstanding <= 500, true); +diff --git a/interop-tests/jasmin.test.ts b/interop-tests/jasmin.test.ts +index c6c9b92..f2fa41d 100644 +--- a/interop-tests/jasmin.test.ts ++++ b/interop-tests/jasmin.test.ts +@@ -86,6 +86,20 @@ const { err: upstreamErr, server: upstream } = await server({ + // was already in flight for Jasmin's own requeue_delay (120s default) before it retries - far past + // any per-test wait budget here - so this is generous specifically to never be the trigger. + idleTimeout: 300_000, ++ onSms: sms => { ++ const variant = (sms.session.userData as { variant?: UpstreamVariant } | undefined)?.variant; ++ ++ if (variant) upstreamSms.push({ sms, variant }); ++ ++ void (async () => { ++ await sms.sendResp(); ++ ++ if (sms.dlr) { ++ await delay(150); ++ await sms.sendDlr('DELIVERED'); ++ } ++ })(); ++ }, + port: UPSTREAM_PORT, + }); + +@@ -97,7 +111,7 @@ const upstreamServer = upstream; + upstreamServer.on('session', session => { + // `session` fires on raw connect, before authenticate() has run - session.userData is not set + // yet, so the map is populated off the bind PDU itself (like kannel.test.ts's bindPdus), not off +- // userData; userData is only read later, from 'sms', where authenticate() has long since run. ++ // userData; userData is only read later, in onSms, where authenticate() has long since run. + session.on('incomingPduObj', pduObj => { + if (!pduObj.cmdName.startsWith('bind_')) return; + +@@ -105,21 +119,6 @@ upstreamServer.on('session', session => { + + if (variant) upstreamSessions.set(variant, session); + }); +- +- session.on('sms', sms => { +- const variant = (session.userData as { variant?: UpstreamVariant } | undefined)?.variant; +- +- if (variant) upstreamSms.push({ sms, variant }); +- +- void (async () => { +- await sms.sendResp(); +- +- if (sms.dlr) { +- await delay(150); +- await sms.sendDlr('DELIVERED'); +- } +- })(); +- }); + }); + + async function waitForUpstreamSession(variant: UpstreamVariant, budget = 20_000): Promise { +@@ -227,7 +226,7 @@ async function sendUdhMo(session: Session, opts: { from: string; message: string + const multipart = segments.length > 1; + + for (const segment of segments) { +- const params = submitSmParams({ from: opts.from, message: opts.message, to: opts.to }, segment, { encoding: 'ASCII', multipart }); ++ const params = submitSmParams({ from: opts.from, message: opts.message, to: opts.to }, segment, { encoding: 'GSM', multipart }); + const sent = await session.send({ cmdName: 'deliver_sm', params }); + + assert.equal(sent.err, undefined); +@@ -247,7 +246,7 @@ async function sendMessagePayloadMo(session: Session, opts: { from: string; mess + }, + tlvs: { + // The body is octets under the PDU's own data_coding wherever it is carried, and 0 is GSM. +- message_payload: { tagValue: encodeMessage(opts.message, 'ASCII').buffer }, ++ message_payload: { tagValue: encodeMessage(opts.message, 'GSM').buffer }, + }, + }); + } +@@ -397,15 +396,12 @@ describe('S7 - Jasmin as the ESME against our server (HTTP send API, DLR callbac + + test('a long GSM message from our server reassembles at Jasmin (or is recorded as fragments)', async () => { + const upstreamSession = await waitForUpstreamSession('main'); +- const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' }); ++ const sms: Sms[] = []; ++ const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver', onSms: s => { sms.push(s); } }); + + assert.equal(err, undefined); + assert.ok(session); + +- const sms: Sms[] = []; +- +- session.on('sms', s => { sms.push(s); }); +- + const text = `s7-long-${'p'.repeat(300)}`; + + await sendUdhMo(upstreamSession, { from: TO, message: text, to: FROM }); +@@ -418,15 +414,12 @@ describe('S7 - Jasmin as the ESME against our server (HTTP send API, DLR callbac + + test('a UCS-2 message with 一 and an emoji from our server (or is recorded as fragments)', async () => { + const upstreamSession = await waitForUpstreamSession('main'); +- const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' }); ++ const sms: Sms[] = []; ++ const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver', onSms: s => { sms.push(s); } }); + + assert.equal(err, undefined); + assert.ok(session); + +- const sms: Sms[] = []; +- +- session.on('sms', s => { sms.push(s); }); +- + const text = `一😀${'q'.repeat(60)}`; + + await sendSarMo(upstreamSession, { from: TO, message: text, to: FROM }); +@@ -502,15 +495,12 @@ describe('C3+C7 - long MT through the fake upstream, receipts and id consistency + describe('C8 (target 3) - long MO from an upstream SMSC, SAR vs UDH segmentation', () => { + test('SAR-segmented deliver_sm from the fake upstream', async () => { + const upstreamSession = await waitForUpstreamSession('main'); +- const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' }); ++ const sms: Sms[] = []; ++ const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver', onSms: s => { sms.push(s); } }); + + assert.equal(err, undefined); + assert.ok(session); + +- const sms: Sms[] = []; +- +- session.on('sms', s => { sms.push(s); }); +- + const text = `sar-mo-${'m'.repeat(300)}`; + + await sendSarMo(upstreamSession, { from: TO, message: text, to: FROM }); +@@ -526,15 +516,12 @@ describe('C8 (target 3) - long MO from an upstream SMSC, SAR vs UDH segmentation + + test('UDH-segmented deliver_sm from the fake upstream', async () => { + const upstreamSession = await waitForUpstreamSession('main'); +- const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' }); ++ const sms: Sms[] = []; ++ const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver', onSms: s => { sms.push(s); } }); + + assert.equal(err, undefined); + assert.ok(session); + +- const sms: Sms[] = []; +- +- session.on('sms', s => { sms.push(s); }); +- + const text = `udh-mo-${'n'.repeat(300)}`; + + await sendUdhMo(upstreamSession, { from: TO, message: text, to: FROM }); +@@ -551,15 +538,12 @@ describe('C8 (target 3) - long MO from an upstream SMSC, SAR vs UDH segmentation + describe('C8 (target 2) - message_payload with sm_length 0', () => { + test('a deliver_sm carrying message_payload instead of short_message', async () => { + const upstreamSession = await waitForUpstreamSession('main'); +- const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' }); ++ const sms: Sms[] = []; ++ const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver', onSms: s => { sms.push(s); } }); + + assert.equal(err, undefined); + assert.ok(session); + +- const sms: Sms[] = []; +- +- session.on('sms', s => { sms.push(s); }); +- + const text = 'message-payload only, sm_length 0'; + const pushed = await sendMessagePayloadMo(upstreamSession, { from: TO, message: text, to: FROM }); + +diff --git a/interop-tests/jsmpp.test.ts b/interop-tests/jsmpp.test.ts +index db96513..21d2c45 100644 +--- a/interop-tests/jsmpp.test.ts ++++ b/interop-tests/jsmpp.test.ts +@@ -46,6 +46,10 @@ const manualTexts = new Set(); + const { err: serverErr, server: smpp } = await server({ + authenticate: () => true, + idleTimeout: 40_000, ++ onSms: sms => { ++ allSms.push({ session: sms.session, sms }); ++ if (!manualTexts.has(sms.message)) void sms.sendResp(); ++ }, + port: SMPP_PORT, + }); + +@@ -58,10 +62,6 @@ smppServer.on('session', session => { + session.on('incomingPduObj', pduObj => { + if (pduObj.cmdName.startsWith('bind_')) bindPdus.push(pduObj.params); + }); +- session.on('sms', sms => { +- allSms.push({ session, sms }); +- if (!manualTexts.has(sms.message)) void sms.sendResp(); +- }); + session.on('sessionError', err => { allSessionErrors.push({ err, session }); }); + }); + +diff --git a/interop-tests/kannel.test.ts b/interop-tests/kannel.test.ts +index 0d911d0..7324305 100644 +--- a/interop-tests/kannel.test.ts ++++ b/interop-tests/kannel.test.ts +@@ -154,6 +154,15 @@ const { err: serverErr, server: smpp } = await server({ + return variant ? { userData: { variant } } : false; + }, + idleTimeout: 40_000, ++ onSms: sms => { ++ const variant = (sms.session.userData as { variant?: Variant } | undefined)?.variant; ++ ++ if (variant) allSms.push({ sms, variant }); ++ ++ // max-pending-submits=1 means bearerbox holds the link to one outstanding submit_sm at a ++ // time - answer each as it lands, or the whole burst stalls behind the first message. ++ if (variant === 'maxp1') void sms.sendResp(); ++ }, + port: SMPP_PORT, + }); + +@@ -171,12 +180,6 @@ smppServer.on('session', session => { + if (variant) bindPdus.push({ params: pduObj.params, variant }); + }); + +- session.on('sms', sms => { +- const variant = (session.userData as { variant?: Variant } | undefined)?.variant; +- +- if (variant) allSms.push({ sms, variant }); +- }); +- + session.on('dlr', dlr => { + const variant = (session.userData as { variant?: Variant } | undefined)?.variant; + +@@ -517,10 +520,6 @@ describe('maxp1 variant - max-pending-submits 1', () => { + + assert.ok(session); + +- // max-pending-submits=1 means bearerbox holds the link to one outstanding submit_sm at a +- // time - answer each as it lands, or the whole burst stalls behind the first message. +- session.on('sms', sms => { void sms.sendResp(); }); +- + const texts = Array.from({ length: 20 }, (_, i) => `burst-${String(i).padStart(2, '0')}`); + + // Sequential, not Promise.all: concurrent fetch()es reach smsbox's HTTP listener in whatever +diff --git a/interop-tests/php.test.ts b/interop-tests/php.test.ts +index 0415ce9..c273f93 100644 +--- a/interop-tests/php.test.ts ++++ b/interop-tests/php.test.ts +@@ -48,6 +48,17 @@ const { err: serverErr, server: smpp } = await server({ + + return true; + }, ++ onSms: sms => { ++ const systemId = systemIdBySession.get(sms.session); ++ ++ if (systemId) allSms.push({ sms, systemId }); ++ ++ // php-smpp's submit_sm() blocks synchronously reading the response on the same connection ++ // that sent it, so answering here (rather than after this event's own test observes the ++ // sms) is the only way that read ever completes - unlike python-smpplib's driver, this one ++ // has no separate reader thread to poll afterwards. ++ void sms.sendResp(); ++ }, + port: SMPP_PORT, + }); + +@@ -62,18 +73,6 @@ smppServer.on('session', session => { + + systemIdBySession.set(session, paramText(pduObj.params.system_id)); + }); +- +- session.on('sms', sms => { +- const systemId = systemIdBySession.get(session); +- +- if (systemId) allSms.push({ sms, systemId }); +- +- // php-smpp's submit_sm() blocks synchronously reading the response on the same connection +- // that sent it, so answering here (rather than after this event's own test observes the +- // sms) is the only way that read ever completes - unlike python-smpplib's driver, this one +- // has no separate reader thread to poll afterwards. +- void sms.sendResp(); +- }); + }); + + after(async () => { +diff --git a/interop-tests/python.test.ts b/interop-tests/python.test.ts +index 84f1c66..97631c0 100644 +--- a/interop-tests/python.test.ts ++++ b/interop-tests/python.test.ts +@@ -68,6 +68,11 @@ const { err: serverErr, server: smpp } = await server({ + + return true; + }, ++ onSms: sms => { ++ const systemId = systemIdBySession.get(sms.session); ++ ++ if (systemId) allSms.push({ sms, systemId }); ++ }, + port: SMPP_PORT, + }); + +@@ -82,12 +87,6 @@ smppServer.on('session', session => { + + systemIdBySession.set(session, paramText(pduObj.params.system_id)); + }); +- +- session.on('sms', sms => { +- const systemId = systemIdBySession.get(session); +- +- if (systemId) allSms.push({ sms, systemId }); +- }); + }); + + after(async () => { +@@ -149,7 +148,7 @@ async function waitForAck(name: string, sequence: number, budget = 8000): Promis + } + + async function echoBack(session: Session, sms: Sms, dataCoding: number, text: string): Promise { +- const encName = dataCoding === 3 ? 'LATIN1' : dataCoding === 8 ? 'UCS2' : 'ASCII'; ++ const encName = dataCoding === 3 ? 'LATIN1' : dataCoding === 8 ? 'UCS2' : 'GSM'; + const buf = encodings[encName].encode(text); + const sent = await session.send({ + cmdName: 'deliver_sm', +diff --git a/interop-tests/smppsim.test.ts b/interop-tests/smppsim.test.ts +index 8a08817..b5e775d 100644 +--- a/interop-tests/smppsim.test.ts ++++ b/interop-tests/smppsim.test.ts +@@ -72,12 +72,11 @@ function collectDlrs(session: Session): Received[] { + return received; + } + +-function collectSms(session: Session): Sms[] { ++/** Every message a client's handler was handed; `onSms` goes to the bind. */ ++function smsInbox(): { collected: Sms[]; onSms: (sms: Sms) => void } { + const collected: Sms[] = []; + +- session.on('sms', sms => { collected.push(sms); }); +- +- return collected; ++ return { collected, onSms: sms => { collected.push(sms); } }; + } + + const DLR_RETRY_BUDGET_MS = 3000; +@@ -202,14 +201,15 @@ describe('smppsim - C3+C7 long MT, receipts and loopback reassembly', () => { + + for (const testCase of cases) { + test(testCase.label, async t => { +- const { err, session } = await bind(PEER_HOST); ++ const inbox = smsInbox(); ++ const { err, session } = await bind(PEER_HOST, { onSms: inbox.onSms }); + + assert.equal(err, undefined); + assert.ok(session); + closeAfter(t, session); + + const dlrs = collectDlrs(session); +- const sms = collectSms(session); ++ const sms = inbox.collected; + + const { reassembled, smsIds } = await sendUntilComplete( + session, +@@ -588,13 +588,14 @@ describe('smppsim - C15 bind version negotiation', () => { + + describe('smppsim - C17 encodings round trip over loopback', () => { + test('Latin-1 (å ä ö)', async t => { +- const { err, session } = await bind(PEER_HOST); ++ const inbox = smsInbox(); ++ const { err, session } = await bind(PEER_HOST, { onSms: inbox.onSms }); + + assert.equal(err, undefined); + assert.ok(session); + closeAfter(t, session); + +- const sms = collectSms(session); ++ const sms = inbox.collected; + + await session.sendSms({ encoding: 'LATIN1', from: FROM, message: 'å ä ö', to: TO }); + +@@ -605,13 +606,14 @@ describe('smppsim - C17 encodings round trip over loopback', () => { + }); + + test('UCS-2', async t => { +- const { err, session } = await bind(PEER_HOST); ++ const inbox = smsInbox(); ++ const { err, session } = await bind(PEER_HOST, { onSms: inbox.onSms }); + + assert.equal(err, undefined); + assert.ok(session); + closeAfter(t, session); + +- const sms = collectSms(session); ++ const sms = inbox.collected; + + await session.sendSms({ encoding: 'UCS2', from: FROM, message: 'ucs2 round trip', to: TO }); + +@@ -622,13 +624,14 @@ describe('smppsim - C17 encodings round trip over loopback', () => { + }); + + test('flash (data_coding records the message-class group)', async t => { +- const { err, session } = await bind(PEER_HOST); ++ const inbox = smsInbox(); ++ const { err, session } = await bind(PEER_HOST, { onSms: inbox.onSms }); + + assert.equal(err, undefined); + assert.ok(session); + closeAfter(t, session); + +- const sms = collectSms(session); ++ const sms = inbox.collected; + + await session.sendSms({ flash: true, from: FROM, message: 'flash test', to: TO }); + +@@ -640,13 +643,14 @@ describe('smppsim - C17 encodings round trip over loopback', () => { + }); + + test('a raw submit_sm with data_coding 0xF0 is read as flash', async t => { +- const { err, session } = await bind(PEER_HOST); ++ const inbox = smsInbox(); ++ const { err, session } = await bind(PEER_HOST, { onSms: inbox.onSms }); + + assert.equal(err, undefined); + assert.ok(session); + closeAfter(t, session); + +- const sms = collectSms(session); ++ const sms = inbox.collected; + const body = 'message class test'; + + const sent = await session.send({ +@@ -668,13 +672,14 @@ describe('smppsim - C17 encodings round trip over loopback', () => { + }); + + test('a raw submit_sm with 8-bit binary and a UDH (esm_class 0x40)', async t => { +- const { err, session } = await bind(PEER_HOST); ++ const inbox = smsInbox(); ++ const { err, session } = await bind(PEER_HOST, { onSms: inbox.onSms }); + + assert.equal(err, undefined); + assert.ok(session); + closeAfter(t, session); + +- const sms = collectSms(session); ++ const sms = inbox.collected; + // A UDH carrying no recognised concatenation IE (0x00/0x08): one element in GSM 03.40's + // reserved-for-future-use range (0x70), so Wireshark's gsm_sms_ud dissector - which + // validates the *typed* IEs' own lengths (0x01 "Special SMS Message Indication" must be +diff --git a/interop-tests/smscsim.test.ts b/interop-tests/smscsim.test.ts +index ba23042..0c1785c 100644 +--- a/interop-tests/smscsim.test.ts ++++ b/interop-tests/smscsim.test.ts +@@ -163,9 +163,11 @@ describe('smscsim - multipart segments', () => { + + describe('smscsim - MO injection through the web UI', () => { + test('a message posted to the web page arrives as an sms event', async t => { ++ const incoming: Sms[] = []; + const { err, session } = await client({ + bindType: 'transceiver', + host: PEER_HOST, ++ onSms: sms => { incoming.push(sms); }, + port: PEER_PORT, + username: 'mo-inject', + }); +@@ -174,10 +176,6 @@ describe('smscsim - MO injection through the web UI', () => { + assert.ok(session); + closeAfter(t, session); + +- const incoming: Sms[] = []; +- +- session.on('sms', sms => { incoming.push(sms); }); +- + const response = await fetch(`http://${PEER_HOST}:${String(PEER_WEB_PORT)}/`, { + body: new URLSearchParams({ + message: 'hello from the web UI', +diff --git a/src/bind-direction.ts b/src/bind-direction.ts +new file mode 100644 +index 0000000..b810cf6 +--- /dev/null ++++ b/src/bind-direction.ts +@@ -0,0 +1,71 @@ ++import type { Result } from './result.ts'; ++import { namedValue } from './error-from.ts'; ++ ++export type BindType = 'receiver' | 'transceiver' | 'transmitter'; ++ ++const bindTypes: readonly BindType[] = ['receiver', 'transceiver', 'transmitter']; ++ ++export const bindCommands: readonly string[] = bindTypes.map(bindType => `bind_${bindType}`); ++ ++export function bindTypeFromCommand(cmdName: string): BindType | undefined { ++ return bindTypes.find(bindType => `bind_${bindType}` === cmdName); ++} ++ ++function isBindType(value: unknown): value is BindType { ++ return bindTypes.some(bindType => bindType === value); ++} ++ ++/** Which end of the link a session is. Only `server()` is the SMSC; everything else is the ESME. */ ++export type LinkEnd = 'esme' | 'smsc'; ++ ++/** ++ * 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 quoted(value: unknown): string { ++ return typeof value === 'string' ? JSON.stringify(value) : namedValue(value); ++} ++ ++/** 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..3ddd716 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 { OnSms, ReconnectOptions } from './session-options.ts'; + import type { SmppLog } from './log.ts'; + import type { SmsIdFormat } from './sms-id.ts'; + import type { Socket } from 'node:net'; +@@ -11,7 +12,7 @@ import { Session } from './session.ts'; + import { checkSessionOptions } from './session-options.ts'; + import { connect as netConnect } from 'node:net'; + import { connect as tlsConnect } from 'node:tls'; +-import { defaultInterfaceVersion } from './defs/constants.ts'; ++import { defaults } from './defaults.ts'; + import { guardedLog } from './log.ts'; + + /** `fromStart` puts the very first connect and bind through the same backoff loop as a drop. */ +@@ -29,6 +30,7 @@ export type ClientOptions = { + interfaceVersion?: number; + log?: SmppLog; + maxOutstanding?: number; ++ onSms?: OnSms; + password?: string; + port?: number; + reconnect?: ReconnectTuning | false; +@@ -41,19 +43,6 @@ export type ClientOptions = { + username?: string; + }; + +-const defaults = { +- bindType: 'transceiver', +- connectTimeout: 10_000, +- enquireLinkInterval: 20_000, +- host: 'localhost', +- /** The idle timeout is what notices a dead link, so it has to outlast one silent probe. */ +- idleTimeoutFactor: 2, +- interfaceVersion: defaultInterfaceVersion, +- password: 'pass', +- port: 2775, +- username: 'user', +-} as const; +- + function armConnectTimeout( + sock: Socket, + connectTimeout: number | false, +@@ -201,9 +190,11 @@ function createSession(options: ClientOptions, log: SmppLog, sock: Socket): Sess + + return new Session({ + enquireLinkInterval, +- idleTimeout: options.idleTimeout ?? enquireLinkInterval * defaults.idleTimeoutFactor, ++ // Two silent probes: one lost enquire_link must not drop a live link. ++ idleTimeout: options.idleTimeout ?? enquireLinkInterval * 2, + log, + maxOutstanding: options.maxOutstanding, ++ onSms: options.onSms, + reconnect: reconnectFor(options, log), + responseTimeout: options.responseTimeout, + shutdownTimeout: options.shutdownTimeout, +diff --git a/src/defaults.ts b/src/defaults.ts +new file mode 100644 +index 0000000..dcaa84b +--- /dev/null ++++ b/src/defaults.ts +@@ -0,0 +1,31 @@ ++import { defaultInterfaceVersion } from './defs/constants.ts'; ++ ++/** Every default the session layer runs on. README's option tables quote these. */ ++export const defaults = { ++ bindType: 'transceiver', ++ connectTimeout: 10_000, ++ /** Receipts of a multipart message can be a working day apart, so the cap does the bounding. */ ++ dlrMergeTimeout: 86_400_000, ++ enquireLinkInterval: 20_000, ++ /** A handler still running this long is no longer counted or waited for. */ ++ handlerTimeout: 300_000, ++ host: 'localhost', ++ /** Two silent enquire_link intervals, so one lost probe does not drop a live link. */ ++ idleTimeout: 40_000, ++ interfaceVersion: defaultInterfaceVersion, ++ maxDlrMerges: 1000, ++ maxHandledMessages: 1000, ++ maxHandledOctets: 64 * 1024 * 1024, ++ maxOutstanding: 10, ++ maxReassembly: 1000, ++ maxReassemblyOctets: 64 * 1024 * 1024, ++ password: 'pass', ++ port: 2775, ++ reassemblyTimeout: 300_000, ++ reconnectMaxDelay: 30_000, ++ reconnectMinDelay: 1000, ++ responseTimeout: 30_000, ++ shutdownTimeout: 5000, ++ systemId: '', ++ username: 'user', ++} as const; +diff --git a/src/defs/encodings.ts b/src/defs/encodings.ts +index c454b02..a1c6216 100644 +--- a/src/defs/encodings.ts ++++ b/src/defs/encodings.ts +@@ -1,4 +1,4 @@ +-export type EncodingName = 'ASCII' | 'LATIN1' | 'UCS2'; ++export type EncodingName = 'GSM' | 'LATIN1' | 'UCS2'; + + export type Encoding = { + decode: (buffer: Uint8Array) => string; +@@ -103,7 +103,7 @@ const ucs2: Encoding = { + }; + + export const encodings: Record = { +- ASCII: ascii, ++ GSM: ascii, + LATIN1: latin1, + UCS2: ucs2, + }; +@@ -115,7 +115,7 @@ export function isEncodingName(value: unknown): value is EncodingName { + } + + export function detect(value: string): EncodingName { +- if (encodings.ASCII.match(value)) return 'ASCII'; ++ if (encodings.GSM.match(value)) return 'GSM'; + if (encodings.LATIN1.match(value)) return 'LATIN1'; + + return 'UCS2'; +@@ -163,21 +163,21 @@ function messageClassEncoding(dataCoding: number): EncodingName | undefined { + if (messageClassOf(dataCoding) === undefined) return undefined; + + if ((dataCoding & 0xF0) === 0xF0) { +- return (dataCoding & 0x04) === 0x04 ? 'LATIN1' : 'ASCII'; ++ return (dataCoding & 0x04) === 0x04 ? 'LATIN1' : 'GSM'; + } + + const alphabet = (dataCoding >> 2) & 0x03; + + if (alphabet === 0x01) return 'LATIN1'; + +- return alphabet === 0x02 ? 'UCS2' : 'ASCII'; ++ return alphabet === 0x02 ? 'UCS2' : 'GSM'; + } + + /** + * SMPP data_coding is a flat table for 0x00-0x0E, and the message class ranges are how a flash UCS2 + * message arrives as 0x18. The 8-bit binary codings resolve to LATIN1, the one codec here that maps + * every octet to a code point and back unchanged, so a binary payload survives; alphabets with no +- * codec fall back to ASCII. ++ * codec fall back to GSM. + */ + export function encodingByDataCoding(dataCoding: number): EncodingName { + const messageClass = messageClassEncoding(dataCoding); +@@ -186,7 +186,7 @@ export function encodingByDataCoding(dataCoding: number): EncodingName { + if (dataCoding === 0x08) return 'UCS2'; + + // 0x02 and 0x04 are 8-bit binary, 0x03 is Latin-1. +- return dataCoding >= 0x02 && dataCoding <= 0x04 ? 'LATIN1' : 'ASCII'; ++ return dataCoding >= 0x02 && dataCoding <= 0x04 ? 'LATIN1' : 'GSM'; + } + + /** +@@ -194,7 +194,7 @@ export function encodingByDataCoding(dataCoding: number): EncodingName { + * takes 0x00, the SMSC default alphabet, rather than SMPP 3.4 5.2.19's 0x01, which is IA5. + */ + export const dataCodingByEncoding: Readonly> = { +- ASCII: 0x00, ++ GSM: 0x00, + LATIN1: 0x03, + UCS2: 0x08, + }; +diff --git a/src/dlr-merger.ts b/src/dlr-merger.ts +index 0c20cae..b61529a 100644 +--- a/src/dlr-merger.ts ++++ b/src/dlr-merger.ts +@@ -126,7 +126,7 @@ export class DlrMerger { + + if (group.parts.size < group.expected.size) return undefined; + +- this.close(base); ++ this.spend(base); + + const segments = [...group.parts.entries()].sort(([a], [b]) => a - b).map(([, one]) => one); + const worst = segments.reduce((carry, one) => (severity[one.statusMsg] > severity[carry.statusMsg] ? one : carry)); +@@ -142,7 +142,7 @@ export class DlrMerger { + /** Drops every group past its deadline. Runs before each collect and on its own timer. */ + sweep(): void { + for (const [base, group] of this.groups.takeExpired()) { +- this.close(base); ++ this.spend(base); + this.log.info('dlrMerger - incomplete receipts expired', { base, expected: group.expected.size }); + } + } +@@ -151,7 +151,7 @@ export class DlrMerger { + this.spent.takeExpired(); + + if (this.groups.get(base) !== undefined || this.spent.get(base) === true) { +- this.close(base); ++ this.spend(base); + this.log.info('dlrMerger - message id handed out again, leaving its receipts unmerged', { base }); + + return; +@@ -162,7 +162,8 @@ export class DlrMerger { + this.groups.set(base, { expected, parts: new Map() }); + } + +- private close(base: string): void { ++ /** The base is finished with, merged or not, and never opens again. */ ++ private spend(base: string): void { + this.groups.delete(base); + this.spent.delete(base); + +@@ -178,7 +179,7 @@ export class DlrMerger { + + const [base] = oldest; + +- this.close(base); ++ this.spend(base); + this.log.warn('dlrMerger - buffer full, dropping the oldest message', { base, max: this.max }); + } + } +diff --git a/src/handled-messages.ts b/src/handled-messages.ts +new file mode 100644 +index 0000000..47776b3 +--- /dev/null ++++ b/src/handled-messages.ts +@@ -0,0 +1,157 @@ ++import type { ErrorName } from './defs/errors.ts'; ++import type { OnSms } from './session-options.ts'; ++import type { Sms, SmsHandlers, SmsInput } from './sms.ts'; ++import type { SmppLog } from './log.ts'; ++import { ExpiringGroups } from './expiring-groups.ts'; ++import { IdleWaiters } from './idle-waiters.ts'; ++import { createSms } from './sms.ts'; ++import { errorFrom } from './error-from.ts'; ++import { retainedOctets } from './retained-pdu.ts'; ++ ++export type HandledMessagesOptions = { ++ log: SmppLog; ++ max: number; ++ maxOctets: number; ++ /** Injected so expiry can be exercised without a wall clock. */ ++ now?: (() => number) | undefined; ++ onSms: OnSms | undefined; ++ /** Where a handler's failure is reported. */ ++ report: (err: Error) => void; ++ timeout: number; ++}; ++ ++/** What a message needs from the session, less the notice this class takes for itself. */ ++export type SmsRoute = Omit; ++ ++type Handled = { answered: boolean; sms: Sms }; ++ ++/** ++ * The messages whose handler is running. Each counts toward the bound and holds a shutdown until ++ * the handler settles, its deadline passes, or the link goes. A message a handler failed on, or ++ * that no handler takes, is answered here where it was not, asking the peer to retry. ++ */ ++export class HandledMessages { ++ private readonly idleWaiters = new IdleWaiters(); ++ private readonly log: SmppLog; ++ private readonly max: number; ++ private readonly maxOctets: number; ++ private readonly onSms: OnSms | undefined; ++ private readonly report: (err: Error) => void; ++ private readonly running: ExpiringGroups; ++ private atBound = false; ++ private keys = 0; ++ ++ constructor(options: HandledMessagesOptions) { ++ this.log = options.log; ++ this.max = options.max; ++ this.maxOctets = options.maxOctets; ++ this.onSms = options.onSms; ++ this.report = options.report; ++ this.running = new ExpiringGroups({ ++ max: options.max, ++ now: options.now, ++ onSweep: () => { this.sweep(); }, ++ timeout: options.timeout, ++ }); ++ } ++ ++ get octets(): number { ++ return this.running.weight; ++ } ++ ++ get size(): number { ++ return this.running.size; ++ } ++ ++ /** Whether a message arriving now is refused: at the bound, and until the store is half empty again. */ ++ refuses(): boolean { ++ this.sweep(); ++ ++ if (this.running.full || this.running.weight >= this.maxOctets) { ++ if (!this.atBound) { ++ this.atBound = true; ++ this.log.warn('handledMessages - messages at their bound, refusing new ones until handlers return', { ++ messages: this.size, ++ octets: this.octets, ++ }); ++ } ++ ++ return true; ++ } ++ ++ // Half, so a peer keeping its window full does not flip this on every answer. ++ if (this.atBound && this.size <= this.max / 2 && this.octets <= this.maxOctets / 2) { ++ this.atBound = false; ++ this.log.info('handledMessages - messages down to half their bound, accepting again', { messages: this.size }); ++ } ++ ++ return false; ++ } ++ ++ /** Hands the message to the handler. `retryStatus` answers it where the handler fails first. */ ++ offer(input: SmsInput, route: SmsRoute, retryStatus: ErrorName): Sms { ++ const key = String(this.keys++); ++ const handled: Handled = { answered: false, sms: createSms(input, { ...route, answered: () => { handled.answered = true; } }) }; ++ ++ this.sweep(); ++ this.running.set(key, handled); ++ this.running.weigh(key, input.pduObjs.reduce((sum, pduObj) => sum + retainedOctets(pduObj), 0)); ++ void this.run(key, handled, retryStatus); ++ ++ return handled.sms; ++ } ++ ++ /** Drops every message: their segments went with the link, so no answer of ours correlates now. */ ++ clear(): void { ++ this.running.takeAll(); ++ this.idleWaiters.settle(); ++ } ++ ++ /** Resolves 0 once every handler has returned, or with how many have not. */ ++ idle(timeout: number, signal: AbortSignal | undefined): Promise { ++ return this.idleWaiters.wait(() => this.running.size, timeout, signal); ++ } ++ ++ /** Drops every message past its deadline. Runs before each offer and on its own timer. */ ++ sweep(): void { ++ const expired = this.running.takeExpired(); ++ ++ if (expired.length === 0) return; ++ ++ this.log.warn('handledMessages - handlers still running past their deadline', { messages: expired.length }); ++ this.settle(); ++ } ++ ++ private async run(key: string, handled: Handled, retryStatus: ErrorName): Promise { ++ const failure = await this.handle(handled.sms); ++ ++ if (failure) { ++ this.log.error('handledMessages - a handler failed', { message: failure.message }); ++ this.report(failure); ++ ++ // A handler that failed before answering has decided nothing, so the peer keeps the message. ++ if (!handled.answered) await handled.sms.sendResp({ status: retryStatus }); ++ } ++ ++ if (this.running.get(key) !== handled) return; ++ ++ this.running.delete(key); ++ this.settle(); ++ } ++ ++ private async handle(sms: Sms): Promise { ++ if (!this.onSms) return new Error('No onSms handler takes inbound messages'); ++ ++ try { ++ await this.onSms(sms); ++ ++ return undefined; ++ } catch (thrown: unknown) { ++ return errorFrom(thrown); ++ } ++ } ++ ++ private settle(): void { ++ if (this.running.size === 0) this.idleWaiters.settle(); ++ } ++} +diff --git a/src/held-messages.ts b/src/held-messages.ts +deleted file mode 100644 +index b9e740e..0000000 +--- a/src/held-messages.ts ++++ /dev/null +@@ -1,201 +0,0 @@ +-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'; +-import type { SmsHandlers } from './sms.ts'; +-import type { SmppLog } from './log.ts'; +-import { ExpiringGroups } from './expiring-groups.ts'; +-import { IdleWaiters } from './idle-waiters.ts'; +-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; +- sendPastDrain: SmsHandlers['send']; +- session: Session; +- timeout: number; +-}; +- +-/** The peer's own sequence number, which is what our answer to this message will carry. */ +-function keyOf(pduObjs: PduObject[]): string | undefined { +- const first = pduObjs[0]; +- +- return first ? String(first.seqNr) : undefined; +-} +- +-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. +- */ +-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> { +- 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; +- 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(); +- private readonly route: HoldRoute; +- +- constructor(options: HeldMessagesOptions) { +- this.held = new ExpiringGroups({ +- max: options.max, +- now: options.now, +- 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 }; +- } +- +- get octetsHeld(): number { +- return this.held.weight; +- } +- +- get size(): number { +- return this.held.size; +- } +- +- /** Whether a message arriving now is past the bound, once the expired are swept. */ +- 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); +- +- this.sweep(); +- +- if (this.held.get(key)) { +- this.log.warn('heldMessages - replacing a message on a re-used sequence number', { seqNr: Number(key) }); +- } +- +- this.held.set(key, pduObjs); +- this.held.weigh(key, pduObjs.reduce((sum, pduObj) => sum + retainedOctets(pduObj), 0)); +- +- return hold; +- } +- +- offer(pduObjs: PduObject[], answeredAs?: string): MessageHold | 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); +- +- this.offered.set(sms, hold); +- +- if (!this.route.session.emit('sms', sms)) hold.release(); +- +- return hold; +- } +- +- /** 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(); +- } +- +- holds(pduObjs: PduObject[]): boolean { +- const key = keyOf(pduObjs); +- +- return key !== undefined && this.held.get(key) === pduObjs; +- } +- +- release(pduObjs: PduObject[]): void { +- const key = keyOf(pduObjs); +- +- // Identity, not the key: a wrapped sequence number must not release someone else's message. +- if (key === undefined || this.held.get(key) !== pduObjs) return; +- +- this.held.delete(key); +- this.settle(); +- } +- +- /** Drops every message: their segments went with the link, so no answer of ours correlates now. */ +- clear(): void { +- this.held.takeAll(); +- this.idleWaiters.settle(); +- } +- +- /** Resolves 0 once every message has been answered, or with how many have not. */ +- idle(timeout: number, signal: AbortSignal | undefined): Promise { +- 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(); +- +- if (expired.length === 0) return; +- +- this.log.warn('heldMessages - messages the application never answered', { +- messages: expired.length, +- }); +- this.settle(); +- } +- +- private settle(): void { +- if (this.held.size === 0) this.idleWaiters.settle(); +- } +-} +diff --git a/src/incoming-requests.ts b/src/incoming-requests.ts +index 51aeec7..35d37f1 100644 +--- a/src/incoming-requests.ts ++++ b/src/incoming-requests.ts +@@ -1,26 +1,27 @@ + 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 { LinkLife } from './link-life.ts'; + import type { LostGroup, Refusal } from './reassembly.ts'; +-import type { OnRequest } from './session-options.ts'; ++import type { OnRequest, OnSms } 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 { SmsHandlers } from './sms.ts'; + import type { SmsIdFormat } from './sms-id.ts'; +-import { HeldMessages } from './held-messages.ts'; ++import type { VoidResult } from './result.ts'; ++import { HandledMessages } from './handled-messages.ts'; + import { Reassembler } from './reassembly.ts'; +-import { bindCommands, defaults, standsInFor } from './session-options.ts'; ++import { bindCommands, standsInFor } from './bind-direction.ts'; + import { concatOf } from './concat.ts'; ++import { defaults } from './defaults.ts'; + import { detach } from './retained-pdu.ts'; + import { dlrFromPdu } from './dlr.ts'; + import { respIdParams, segmentId } from './sms-id.ts'; + import { respNameFor } from './defs/commands.ts'; + + /** Asks the peer to keep the message and retry. */ +-function throttledStatus(carriedAs: string): ErrorName { ++function retryStatus(carriedAs: string): ErrorName { + return carriedAs === 'submit_sm' ? 'ESME_RTHROTTLED' : 'ESME_RX_T_APPN'; + } + +@@ -34,7 +35,7 @@ export function refusedSegmentStatus( + return spelling === 'sar' ? 'ESME_RINVTLVVAL' : 'ESME_RINVESMCLASS'; + } + +- return throttledStatus(carriedAs); ++ return retryStatus(carriedAs); + } + + const lostReasons: Record = { +@@ -44,14 +45,17 @@ const lostReasons: Record = { + }; + + export type IncomingRequestsOptions = { ++ answer: SmsHandlers['answer']; + dlrMerger: DlrMerger; + link: LinkLife; + log: SmppLog; + maxOctets?: number | undefined; + maxReassembly?: number | undefined; + onRequest?: OnRequest | undefined; ++ onSms?: OnSms | undefined; + reassemblyTimeout?: number | undefined; +- sendPastDrain: HeldMessagesOptions['sendPastDrain']; ++ /** Puts a receipt on the wire, on a closing session too. */ ++ sendReceipt: SmsHandlers['send']; + session: Session; + smsIdFormat?: SmsIdFormat | undefined; + systemId?: string | undefined; +@@ -59,27 +63,28 @@ export type IncomingRequestsOptions = { + + /** Everything the peer asks of a session: messages, receipts, links and the answers to them. */ + export class IncomingRequests { ++ private readonly answer: SmsHandlers['answer']; + private readonly dlrMerger: DlrMerger; +- private readonly held: HeldMessages; ++ private readonly handled: HandledMessages; + private readonly link: LinkLife; + private readonly log: SmppLog; + private readonly onRequest: OnRequest | undefined; + private readonly reassembler: Reassembler; ++ private readonly sendReceipt: SmsHandlers['send']; + private readonly session: Session; + private readonly smsIdFormat: SmsIdFormat; + private readonly systemId: string; +- private refusing = false; + + constructor(options: IncomingRequestsOptions) { ++ this.answer = options.answer; + this.dlrMerger = options.dlrMerger; +- this.held = new HeldMessages({ +- link: options.link, ++ this.handled = new HandledMessages({ + log: options.log, +- max: defaults.maxHeldMessages, +- maxOctets: defaults.maxHeldOctets, +- sendPastDrain: options.sendPastDrain, +- session: options.session, +- timeout: defaults.heldMessageTimeout, ++ max: defaults.maxHandledMessages, ++ maxOctets: defaults.maxHandledOctets, ++ onSms: options.onSms, ++ report: err => { options.session.emit('sessionError', err); }, ++ timeout: defaults.handlerTimeout, + }); + this.link = options.link; + this.log = options.log; +@@ -91,6 +96,7 @@ export class IncomingRequests { + onLost: lost => { this.reportLost(lost); }, + timeout: options.reassemblyTimeout ?? defaults.reassemblyTimeout, + }); ++ this.sendReceipt = options.sendReceipt; + this.session = options.session; + this.smsIdFormat = options.smsIdFormat ?? {}; + this.systemId = options.systemId ?? defaults.systemId; +@@ -115,7 +121,7 @@ export class IncomingRequests { + bindType: this.session.boundAs ?? '', + cmdName: pduObj.cmdName, + }); +- await this.session.sendReturn(pduObj, 'ESME_RINVBNDSTS'); ++ this.answer(pduObj, 'ESME_RINVBNDSTS', {}); + + return; + } +@@ -128,52 +134,46 @@ export class IncomingRequests { + case 'data_sm': + case 'deliver_sm': + // A data_sm at the SMSC end is a submission, and a submission is never a report. +- await (this.carriedAs(pduObj) === 'submit_sm' +- ? this.onMessage(pduObj) +- : this.onDelivery(pduObj)); ++ if (this.carriedAs(pduObj) === 'submit_sm') this.onMessage(pduObj); ++ else this.onDelivery(pduObj); + break; + case 'enquire_link': +- await this.session.sendReturn(pduObj); ++ this.answer(pduObj, 'ESME_ROK', {}); + break; + case 'submit_sm': +- await this.onMessage(pduObj); ++ this.onMessage(pduObj); + break; + case 'unbind': +- await this.session.sendReturn(pduObj); ++ this.answer(pduObj, 'ESME_ROK', {}); + // A peer that has said it is finished will not answer what we still have outstanding. + await this.session.close({ signal: AbortSignal.abort() }); + break; + default: +- await this.unhandled(pduObj); ++ this.unhandled(pduObj); + } + } + +- /** 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, and stops waiting on every handler. */ + clear(): void { +- this.refusing = false; +- this.held.clear(); ++ this.handled.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. */ ++ /** Waits out the handlers still running, and says how many never returned. */ + async drain(timeout: number, signal: AbortSignal | undefined): Promise { +- const unanswered = await this.held.idle(timeout, signal); ++ const running = await this.handled.idle(timeout, signal); + +- if (unanswered === 0) return {}; ++ if (running === 0) return {}; + +- this.log.warn('session - shutting down with messages unanswered', { timeout, unanswered }); ++ this.log.warn('session - shutting down with message handlers still running', { running, timeout }); + +- return { err: new Error(`Shut down with ${String(unanswered)} message(s) unanswered`) }; ++ return { err: new Error(`Shut down with ${String(running)} message(s) still being handled`) }; + } + +- private async unhandled(pduObj: PduObject): Promise { ++ private unhandled(pduObj: PduObject): void { + if (bindCommands.includes(pduObj.cmdName)) { + this.log.info('session - bind on an already bound session', { cmdName: pduObj.cmdName }); +- await this.session.sendReturn(pduObj, 'ESME_RALYBND', { system_id: this.systemId }); ++ this.answer(pduObj, 'ESME_RALYBND', { system_id: this.systemId }); + + return; + } +@@ -185,7 +185,7 @@ export class IncomingRequests { + } + + this.log.info('session - no handler for command', { cmdName: pduObj.cmdName }); +- await this.session.sendReturn(pduObj, 'ESME_RINVCMDID'); ++ this.answer(pduObj, 'ESME_RINVCMDID', {}); + } + + private carriedAs(pduObj: PduObject): string { +@@ -193,11 +193,11 @@ export class IncomingRequests { + } + + /** SMPP carries a mobile-originated message and a delivery receipt on the same command. */ +- private async onDelivery(pduObj: PduObject): Promise { ++ private onDelivery(pduObj: PduObject): void { + const dlr = dlrFromPdu(pduObj, this.smsIdFormat); + + if (!dlr) { +- await this.onMessage(pduObj); ++ this.onMessage(pduObj); + + return; + } +@@ -208,52 +208,30 @@ export class IncomingRequests { + + if (merged) this.session.emit('messageDlr', merged); + +- await this.session.sendReturn(pduObj); ++ this.answer(pduObj, 'ESME_ROK', {}); + } + +- private async refusedAtBound(pduObj: PduObject): Promise { +- 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', { ++ /** ++ * A concatenated message is answered segment by segment as it arrives: a peer that dispatches ++ * one request at a time never sends the second segment until the first has been answered. ++ */ ++ private onMessage(pduObj: PduObject): void { ++ const carriedAs = this.carriedAs(pduObj); ++ ++ if (this.handled.refuses()) { ++ this.log.verbose('session - messages at their bound, asking the peer to retry', { + cmdName: pduObj.cmdName, + seqNr: pduObj.seqNr, + }); +- await this.session.sendReturn(pduObj, throttledStatus(this.carriedAs(pduObj))); ++ this.answer(pduObj, retryStatus(carriedAs), {}); + +- 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 }); ++ return; + } + +- return false; +- } +- +- /** +- * A concatenated message is answered segment by segment as it arrives: a peer that dispatches +- * one request at a time never sends the second segment until the first has been answered. +- */ +- private async onMessage(pduObj: PduObject): Promise { +- if (await this.refusedAtBound(pduObj)) return; +- + const concat = concatOf(pduObj); + + if (!concat) { +- this.held.offer([detach(pduObj)]); ++ this.offer([detach(pduObj)], undefined, carriedAs); + + return; + } +@@ -261,21 +239,24 @@ export class IncomingRequests { + const collected = this.reassembler.collect(pduObj, concat); + + if (!collected.kept) { +- await this.session.sendReturn( +- pduObj, +- refusedSegmentStatus(this.carriedAs(pduObj), collected.refusal, concat.spelling), +- ); ++ this.answer(pduObj, refusedSegmentStatus(carriedAs, collected.refusal, concat.spelling), {}); + + return; + } + +- await this.session.sendReturn( +- pduObj, +- 'ESME_ROK', +- respIdParams(pduObj.cmdName, segmentId(collected.smsId, concat.part - 1, concat.total)), +- ); ++ this.answer(pduObj, 'ESME_ROK', respIdParams(pduObj.cmdName, segmentId(collected.smsId, concat.part - 1, concat.total))); ++ ++ if (collected.whole) this.offer(collected.whole, collected.smsId, carriedAs); ++ } ++ ++ private offer(pduObjs: PduObject[], answeredAs: string | undefined, carriedAs: string): void { ++ const generation = this.link.generation(); + +- if (collected.whole) this.held.offer(collected.whole, collected.smsId); ++ this.handled.offer({ answeredAs, pduObjs, session: this.session }, { ++ answer: this.answer, ++ lostLink: () => this.link.generation() !== generation, ++ send: this.sendReceipt, ++ }, retryStatus(carriedAs)); + } + + private reportLost(lost: LostGroup): void { +diff --git a/src/index.ts b/src/index.ts +index 71fa973..2fce278 100644 +--- a/src/index.ts ++++ b/src/index.ts +@@ -53,6 +53,7 @@ export type { + export type { + CloseOptions, + MessageDlr, ++ OnSms, + ReconnectOptions, + SendOptions, + SendSmsOptions, +diff --git a/src/message.ts b/src/message.ts +index 0665019..2e44b5b 100644 +--- a/src/message.ts ++++ b/src/message.ts +@@ -11,7 +11,7 @@ const singleMessageBits = 1120; + export const maxSegments = 255; + + /** Budget per segment: the 134 octets left of 140 after the UDH, or the 153 septets GSM packs into them. */ +-const segmentUnits: Record = { ASCII: 153, LATIN1: 134, UCS2: 134 }; ++const segmentUnits: Record = { GSM: 153, LATIN1: 134, UCS2: 134 }; + + export type SplitOptions = { + encoding?: EncodingName; +@@ -70,7 +70,7 @@ export function bitCount(message: string, encoding?: EncodingName): number { + const encoded = encodings[resolved].encode(message); + + // GSM characters are packed seven bits to a septet; everything else stays octet-aligned. +- return resolved === 'ASCII' ? encoded.length * 7 : encoded.length * 8; ++ return resolved === 'GSM' ? encoded.length * 7 : encoded.length * 8; + } + + /** +diff --git a/src/outgoing-requests.ts b/src/outgoing-requests.ts +index a0adf24..827c009 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 = { +@@ -18,6 +18,11 @@ export type OutgoingRequestsOptions = { + transport: PduTransport; + }; + ++export type RequestOptions = SendOptions & { ++ /** A receipt for a message the shutdown is waiting on: the one request a closing session still takes. */ ++ pastDrain?: boolean | undefined; ++}; ++ + /** `retryOnNextLink`: the write failed, so nothing reached the socket and another link may carry it. */ + type Attempt = { result: Result<{ pduObj: PduObject }>; retryOnNextLink: boolean }; + +@@ -25,13 +30,6 @@ function abortedBeforeSend(): Error { + return new Error('Aborted before the request was sent'); + } + +-/** A response carries the request's sequence number, which only sendReturn() has. */ +-function misuse(input: PduObjectInput): Error | undefined { +- return input.cmdName.endsWith('_resp') +- ? new Error(`Use sendReturn() for responses, not send(): ${input.cmdName}`) +- : undefined; +-} +- + /** Everything this end asks of the peer: which link carries it, how many at once, and the answer. */ + export class OutgoingRequests { + private readonly link: LinkLife; +@@ -69,35 +67,17 @@ export class OutgoingRequests { + this.pending.settle(seqNr, { err }); + } + +- request(input: PduObjectInput, options: SendOptions): Promise> { +- // Ahead of the drain, so a misuse is named as one rather than blamed on the shutdown. +- const wrong = misuse(input); +- +- if (wrong) return Promise.resolve({ err: wrong }); +- +- // With no link, the request is refused as closed further on. +- if (this.link.isStopped() && 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> { +- const refused = this.refuse(input, options); ++ /** ++ * A request, on whichever link carries it: waits for a bound link and a window slot, and waits ++ * again on the next link where nothing of it reached the socket. One budget covers every wait. ++ */ ++ async request(input: PduObjectInput, options: RequestOptions = {}): Promise> { ++ const refused = this.refusal(input, options); + + if (refused) return { 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); +- } ++ // A bind is what makes a link carry anything, so it cannot wait for one. ++ if (bindCommands.includes(input.cmdName)) return this.requestOnLink(input, options); + + const waitForLink = this.link.hold(options.signal); + +@@ -112,15 +92,16 @@ export class OutgoingRequests { + + const attempt = await this.attempt(input, options).finally(() => { this.window.release(); }); + +- if (!this.retriesOnNextLink(attempt)) return attempt.result; ++ if (!attempt.retryOnNextLink || !this.link.awaitsNextLink()) return attempt.result; + } + } + +- /** Straight onto the current link, for what has to go out either way. */ +- async requestOnCurrentLink( +- input: PduObjectInput, +- options: SendOptions = {}, +- ): Promise> { ++ /** Straight onto the attached socket, outside the window: a bind or an unbind opens or closes what the window serves. */ ++ async requestOnLink(input: PduObjectInput, options: SendOptions = {}): Promise> { ++ const over = this.link.refusal(); ++ ++ if (over) return { err: over }; ++ + return (await this.attempt(input, options)).result; + } + +@@ -135,16 +116,21 @@ export class OutgoingRequests { + return { err: new Error(`Shut down with ${String(unfinished)} request(s) unfinished`) }; + } + +- /** 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(); +- } +- + /** Why a request cannot go out at all, as opposed to not yet. */ +- private refuse(input: PduObjectInput, options: SendOptions): Error | undefined { +- // Before the link and the window, or an aborted call waits for what it will never use. +- return misuse(input) ?? (options.signal?.aborted === true ? abortedBeforeSend() : undefined); ++ private refusal(input: PduObjectInput, options: RequestOptions): Error | undefined { ++ // A response carries the request's sequence number, which only sendReturn() has. ++ if (input.cmdName.endsWith('_resp')) { ++ return new Error(`Use sendReturn() for responses, not send(): ${input.cmdName}`); ++ } ++ ++ if (options.signal?.aborted === true) return abortedBeforeSend(); ++ ++ // With no link up, the link's own refusal names the session closed instead. ++ if (options.pastDrain !== true && this.link.isStopped() && this.canCarry()) { ++ return new Error('Session is shutting down'); ++ } ++ ++ return undefined; + } + + private async attempt(input: PduObjectInput, options: SendOptions): Promise { +diff --git a/src/reassembly.ts b/src/reassembly.ts +index 4f3d2c5..d6b02bc 100644 +--- a/src/reassembly.ts ++++ b/src/reassembly.ts +@@ -2,6 +2,7 @@ import type { Concat } from './concat.ts'; + import type { PduObject } from './pdu.ts'; + import type { SmppLog } from './log.ts'; + import { ExpiringGroups } from './expiring-groups.ts'; ++import { defaults } from './defaults.ts'; + import { decodeMessage } from './message.ts'; + import { detach, retainedOctets } from './retained-pdu.ts'; + import { messageOctets } from './message-body.ts'; +@@ -42,8 +43,6 @@ export type Collected = + whole?: PduObject[] | undefined; + }; + +-export const defaultMaxOctets = 64 * 1024 * 1024; +- + type Group = { + parts: Map; + smsId: string; +@@ -89,7 +88,7 @@ export class Reassembler { + private readonly onLost: (lost: LostGroup) => void; + + constructor(options: ReassemblerOptions) { +- this.maxOctets = options.maxOctets ?? defaultMaxOctets; ++ this.maxOctets = options.maxOctets ?? defaults.maxReassemblyOctets; + this.groups = new ExpiringGroups({ + max: options.max, + maxWeight: this.maxOctets, +diff --git a/src/reconnect-loop.ts b/src/reconnect-loop.ts +index af8044d..df41afb 100644 +--- a/src/reconnect-loop.ts ++++ b/src/reconnect-loop.ts +@@ -1,11 +1,7 @@ + import type { Result, VoidResult } from './result.ts'; + import type { SmppLog } from './log.ts'; + import type { Socket } from 'node:net'; +- +-export const backoffDefaults = { +- maxDelay: 30_000, +- minDelay: 1000, +-}; ++import { defaults } from './defaults.ts'; + + export type ReconnectLoopOptions = { + connect: () => Promise>; +@@ -32,8 +28,8 @@ export class ReconnectLoop { + private upAt: number | undefined; + + constructor(options: ReconnectLoopOptions) { +- this.maxDelay = options.maxDelay ?? backoffDefaults.maxDelay; +- this.minDelay = options.minDelay ?? backoffDefaults.minDelay; ++ this.maxDelay = options.maxDelay ?? defaults.reconnectMaxDelay; ++ this.minDelay = options.minDelay ?? defaults.reconnectMinDelay; + this.now = options.now ?? Date.now; + this.options = options; + this.delay = this.minDelay; +diff --git a/src/server.ts b/src/server.ts +index 2b9aefe..798225e 100644 +--- a/src/server.ts ++++ b/src/server.ts +@@ -1,15 +1,17 @@ +-import type { BindType, CloseOptions, OnRequest } from './session-options.ts'; ++import type { BindType } from './bind-direction.ts'; ++import type { CloseOptions, OnRequest, OnSms } 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'; + 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 { Session } from './session.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'; ++import { defaults } from './defaults.ts'; + import { errorFrom } from './error-from.ts'; + import { paramText } from './defs/types.ts'; + import { guardedLog } from './log.ts'; +@@ -35,6 +37,8 @@ export type ServerOptions = { + maxReassembly?: number; + /** First refusal on every request a bound peer sends. */ + onRequest?: OnRequest; ++ /** Takes every message a bound peer submits, on every session. */ ++ onSms?: OnSms; + port?: number; + reassemblyTimeout?: number; + responseTimeout?: number; +@@ -49,13 +53,6 @@ export type ServerEvents = { + session: [Session]; + }; + +-const defaults = { +- idleTimeout: 40_000, +- interfaceVersion: defaultInterfaceVersion, +- port: 2775, +- systemId: defaultSystemId, +-}; +- + /** A listener may return a promise: an `async` one that rejects is routed like one that throws. */ + type ServerListener = (...args: ServerEvents[K]) => unknown; + +@@ -239,6 +236,7 @@ function onConnection(sock: Socket, options: ServerOptions, server: SmppServer): + maxOctets: options.maxOctets, + maxReassembly: options.maxReassembly, + onRequest: (bound, pduObj) => handleRequest(bound, pduObj, options), ++ onSms: options.onSms, + reassemblyTimeout: options.reassemblyTimeout, + responseTimeout: options.responseTimeout, + shutdownTimeout: options.shutdownTimeout, +@@ -269,28 +267,11 @@ function createSecureListener(tlsOptions: TlsOptions, log: SmppLog): TlsServer { + return listener; + } + +-/** A caller without types would otherwise reach a TypeError once per PDU rather than once here. */ +-function checkHooks(options: ServerOptions): VoidResult { +- for (const name of ['authenticate', 'onRequest'] as const) { +- const hook: unknown = options[name]; +- +- if (hook !== undefined && typeof hook !== 'function') { +- return { err: new Error(`${name} must be a function, got ${typeof hook}`) }; +- } +- } +- +- return {}; +-} +- + function checkOptions(options: ServerOptions, log: SmppLog, port: number): VoidResult { + const checked = checkSessionOptions(options); + + if (checked.err) return { err: checked.err }; + +- const hooks = checkHooks(options); +- +- if (hooks.err) return hooks; +- + if (options.tls === true) { + log.warn('server - tls without a certificate', { port }); + +diff --git a/src/session-options.ts b/src/session-options.ts +index 0b768c9..956ca1d 100644 +--- a/src/session-options.ts ++++ b/src/session-options.ts +@@ -8,8 +8,7 @@ import type { SmppLog } from './log.ts'; + import type { SmsIdFormat } from './sms-id.ts'; + import type { Sms } from './sms.ts'; + import type { Socket } from 'node:net'; +-import { backoffDefaults } from './reconnect-loop.ts'; +-import { defaultMaxOctets } from './reassembly.ts'; ++import { defaults } from './defaults.ts'; + import { isSmsIdNotation, smsIdNotations, smsIdPlaces } from './sms-id.ts'; + import { namedValue } from './error-from.ts'; + +@@ -23,56 +22,8 @@ export type SessionEvents = { + messageDlr: [MessageDlr]; + reconnected: []; + sessionError: [Error | PduRefusedError]; +- 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. */ +@@ -85,6 +36,13 @@ export type CloseOptions = { signal?: AbortSignal | undefined }; + */ + export type OnRequest = (session: Session, pduObj: PduObject) => Promise | boolean; + ++/** ++ * Takes every inbound message. The message is held — counted toward the bound, and waited for by ++ * `close()` — until the returned promise settles. A handler that fails before answering has the ++ * message answered for it, asking the peer to retry. ++ */ ++export type OnSms = (sms: Sms) => unknown; ++ + /** + * How to come back after an unexpected disconnect. The session owns the retry loop; the caller + * supplies how to open a socket and what to do once it is open (bind, for a client). +@@ -104,10 +62,11 @@ export type SessionOptions = { + maxOutstanding?: number | undefined; + maxReassembly?: number | undefined; + onRequest?: OnRequest | undefined; ++ onSms?: OnSms | undefined; + reassemblyTimeout?: number | undefined; + reconnect?: ReconnectOptions | undefined; + responseTimeout?: number | undefined; +- /** How long a drain waits for the requests already on the wire. 0 waits forever. */ ++ /** How long a shutdown waits for the handlers still running and the requests already on the wire. 0 waits forever. */ + shutdownTimeout?: number | undefined; + /** The notation the peer writes message ids in, where it is not the one they are compared in. */ + smsIdFormat?: SmsIdFormat | undefined; +@@ -116,52 +75,10 @@ export type SessionOptions = { + systemId?: string | undefined; + }; + +-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, +- /** The peer gave up on an unanswered message long before this; the bound is against growth. */ +- heldMessageTimeout: 300_000, +- maxDlrMerges: 1000, +- maxHeldMessages: 1000, +- maxHeldOctets: 64 * 1024 * 1024, +- maxOutstanding: 10, +- maxReassembly: 1000, +- reassemblyTimeout: 300_000, +- responseTimeout: 30_000, +- shutdownTimeout: 5000, +- systemId: defaultSystemId, +-}; +- + /** + * A count below 1 does not fail loudly anywhere downstream: `maxOutstanding: 0` leaves every send + * queued behind a slot that is never freed, so a send with no `signal` never settles at all. +@@ -171,6 +88,10 @@ export function checkSessionOptions(options: CheckableOptions): VoidResult { + return { err: new Error('fromStart is part of the reconnect policy, spell it reconnect: { fromStart: true }') }; + } + ++ const hooks = checkHooks(options); ++ ++ if (hooks.err) return hooks; ++ + const connect = checkConnectTimeout(options.connectTimeout); + + if (connect.err) return connect; +@@ -184,10 +105,23 @@ export function checkSessionOptions(options: CheckableOptions): VoidResult { + return backoff.err ? backoff : checkSmsIdFormat(options.smsIdFormat); + } + ++/** A caller without types would otherwise reach a TypeError once per PDU rather than once here. */ ++function checkHooks(options: CheckableOptions): VoidResult { ++ for (const name of ['authenticate', 'onRequest', 'onSms'] as const) { ++ const hook = options[name]; ++ ++ if (hook !== undefined && typeof hook !== 'function') { ++ return { err: new Error(`${name} must be a function, got ${typeof hook}`) }; ++ } ++ } ++ ++ return {}; ++} ++ + function limitsOf(options: CheckableOptions): [string, number, number][] { + return [ + ['idleTimeout', options.idleTimeout ?? 0, 0], +- ['maxOctets', options.maxOctets ?? defaultMaxOctets, 1], ++ ['maxOctets', options.maxOctets ?? defaults.maxReassemblyOctets, 1], + ['maxOutstanding', options.maxOutstanding ?? defaults.maxOutstanding, 1], + ['maxReassembly', options.maxReassembly ?? defaults.maxReassembly, 1], + ['reassemblyTimeout', options.reassemblyTimeout ?? defaults.reassemblyTimeout, 0], +@@ -243,8 +177,8 @@ function checkReconnect(reconnect: unknown): VoidResult { + return { err: new Error(`reconnect.fromStart must be true or false, got ${typeof reconnect.fromStart}`) }; + } + +- const maxDelay = delayOr(reconnect.maxDelay, backoffDefaults.maxDelay); +- const minDelay = delayOr(reconnect.minDelay, backoffDefaults.minDelay); ++ const maxDelay = delayOr(reconnect.maxDelay, defaults.reconnectMaxDelay); ++ const minDelay = delayOr(reconnect.minDelay, defaults.reconnectMinDelay); + // A delay of 0 never doubles, so the backoff never starts and every retry lands at once. + const checked = checkLimits([['maxDelay', maxDelay, 1], ['minDelay', minDelay, 1]]); + +@@ -292,6 +226,7 @@ function checkSmsIdFormat(smsIdFormat: unknown): VoidResult { + + /** What the checker reads, as it arrives: a caller without types can put anything in it. */ + export type CheckableOptions = { ++ authenticate?: unknown; + connectTimeout?: unknown; + /** Not an option: the one spelling is inside reconnect, and this is where the other is refused. */ + fromStart?: unknown; +@@ -299,6 +234,8 @@ export type CheckableOptions = { + maxOctets?: number | undefined; + maxOutstanding?: number | undefined; + maxReassembly?: number | undefined; ++ onRequest?: unknown; ++ onSms?: unknown; + reassemblyTimeout?: number | undefined; + reconnect?: unknown; + responseTimeout?: number | undefined; +diff --git a/src/session.ts b/src/session.ts +index 1fd9b46..67ffa1b 100644 +--- a/src/session.ts ++++ b/src/session.ts +@@ -1,9 +1,10 @@ ++import type { BindType, LinkEnd, SessionBind } from './bind-direction.ts'; + import type { ErrorName } from './defs/errors.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, OnSms, 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'; +@@ -17,9 +18,10 @@ 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 { defaults } from './defaults.ts'; + import { errorFrom } from './error-from.ts'; + import { optionalParamsMinVersion } from './defs/constants.ts'; +-import { bindCarries, bindCommands, checkedBind, defaultSystemId, defaults } from './session-options.ts'; + import { isResp, objToPdu, pduReturn } from './pdu.ts'; + import { refusalAnswer } from './pdu-refusal.ts'; + import { guardedLog } from './log.ts'; +@@ -29,6 +31,7 @@ import { ConcatReference } from './udh.ts'; + export type { + CloseOptions, + MessageDlr, ++ OnSms, + ReconnectOptions, + SendOptions, + SendSmsOptions, +@@ -37,7 +40,7 @@ export type { + SessionOptions, + }; + export type { BindType }; +-export { bindCommands, defaultSystemId }; ++export { bindCommands }; + + /** A listener may return a promise: an `async` one that rejects is routed like one that throws. */ + type SessionListener = (...args: SessionEvents[K]) => unknown; +@@ -63,9 +66,10 @@ export class Session extends EventEmitter { + private readonly dlrMerger: DlrMerger; + private readonly incoming: IncomingRequests; + private readonly link: LinkLife; +- private readonly options: SessionOptions; + private readonly outgoing: OutgoingRequests; + private readonly reconnectLoop: ReconnectLoop | undefined; ++ private readonly smsIdFormat: SessionOptions['smsIdFormat']; ++ private readonly shutdownTimeout: number; + private readonly timers: LinkTimers; + private readonly transport: PduTransport; + +@@ -93,34 +97,31 @@ export class Session extends EventEmitter { + reason: unknown, + ...args: [event: keyof SessionEvents, ...rest: unknown[]] + ): void { +- const [event, ...rest] = args; ++ const [event] = args; + const error = errorFrom(reason); + + this.log.error('session - a listener rejected', { event, message: error.message }); + +- if (event === 'sms') this.incoming.listenerRejected(rest[0]); +- + if (event !== 'sessionError') this.emit('sessionError', error); + } + + constructor(options: SessionOptions) { + super({ captureRejections: true }); + ++ const responseTimeout = options.responseTimeout ?? defaults.responseTimeout; ++ + this.log = guardedLog(options.log); +- this.options = options; ++ this.smsIdFormat = options.smsIdFormat; ++ this.shutdownTimeout = options.shutdownTimeout ?? defaults.shutdownTimeout; + this.dlrMerger = new DlrMerger({ log: this.log, max: defaults.maxDlrMerges, timeout: defaults.dlrMergeTimeout }); + this.reconnectLoop = this.loopFor(options.reconnect); +- +- const responseTimeout = options.responseTimeout ?? defaults.responseTimeout; +- + this.link = new LinkLife({ log: this.log, reconnects: this.reconnectLoop !== undefined, timeout: responseTimeout }); + this.timers = new LinkTimers({ + enquireLinkInterval: options.enquireLinkInterval, + 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.linkLost(); }, + }); + this.transport = this.transportFor(options.sock); + this.outgoing = new OutgoingRequests({ +@@ -130,21 +131,39 @@ export class Session extends EventEmitter { + responseTimeout, + transport: this.transport, + }); +- this.incoming = new IncomingRequests({ ++ this.incoming = this.incomingFor(options); ++ this.timers.reset(); ++ } ++ ++ private transportFor(sock: Socket): PduTransport { ++ return new PduTransport({ ++ log: this.log, ++ onClose: () => { this.linkLost(); }, ++ onData: chunk => { this.emit('data', chunk); this.timers.reset(); }, ++ onError: err => { this.emit('sessionError', err); }, ++ onFramed: pdu => { this.emit('incomingPdu', pdu); }, ++ onPdu: pduObj => { this.dispatch(pduObj); }, ++ onRefused: refused => { this.refuse(refused); }, ++ onUnreadable: err => { this.emit('sessionError', err); this.linkLost(); }, ++ }, sock); ++ } ++ ++ private incomingFor(options: SessionOptions): IncomingRequests { ++ return new IncomingRequests({ ++ answer: (pduObj, status, params) => this.answer(pduReturn(pduObj, status, params), pduObj.cmdName, pduObj.seqNr), + dlrMerger: this.dlrMerger, + link: this.link, + log: this.log, + maxOctets: options.maxOctets, + maxReassembly: options.maxReassembly, + onRequest: options.onRequest, ++ onSms: options.onSms, + reassemblyTimeout: options.reassemblyTimeout, +- sendPastDrain: input => this.outgoing.requestPastDrain(input, {}), ++ sendReceipt: input => this.outgoing.request(input, { pastDrain: true }), + session: this, + smsIdFormat: options.smsIdFormat, + systemId: options.systemId, + }); +- +- this.resetTimers(); + } + + /** Replaced on reconnect, so hold the session rather than this. */ +@@ -196,22 +215,6 @@ export class Session extends EventEmitter { + 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 { + if (!this.bindAllows('submit_sm')) { + return unsent(new Error('A receiver-bound session does not carry submit_sm')); +@@ -220,7 +223,7 @@ export class Session extends EventEmitter { + const sent = await submitSms({ + log: this.log, + reference: this.concatReference.next(), +- respIdNotation: this.options.smsIdFormat?.submitResp, ++ respIdNotation: this.smsIdFormat?.submitResp, + send: input => this.send(input, options), + }, sms); + +@@ -230,48 +233,93 @@ export class Session extends EventEmitter { + } + + /** +- * Drains, unbinds politely, then closes. Many SMSCs drop the link instead of answering the ++ * Drains, unbinds politely, then finishes. Many SMSCs drop the link instead of answering the + * unbind, which is fine. Reports the unbind's own failure ahead of an unfinished drain. + */ + async unbind(): Promise { + const drained = await this.drain(undefined); + const wasOpen = this.link.isAttached(); + const sent = wasOpen +- ? await this.outgoing.requestOnCurrentLink({ cmdName: 'unbind' }) ++ ? await this.outgoing.requestOnLink({ cmdName: 'unbind' }) + : { err: new Error('Session is closed') }; + const closedOnUnbind = wasOpen && !this.link.isAttached(); + +- this.end(); ++ this.finish(); + + return sent.err && !closedOnUnbind ? { err: sent.err } : drained; + } + +- /** +- * Closes for good: refuses new sends, waits up to `shutdownTimeout` for the requests already sent +- * and the messages not yet answered, then tears down whatever is left. A session closed this way never reconnects. +- */ ++ /** Drains, then finishes: the session never reconnects after this. */ + async close(options: CloseOptions = {}): Promise { + const drained = await this.drain(options.signal); + +- this.end(); ++ this.finish(); + + return drained; + } + +- private transportFor(sock: Socket): PduTransport { +- return new PduTransport({ +- log: this.log, +- onClose: () => { this.onClose(); }, +- onData: chunk => { this.onData(chunk); }, +- onError: err => { this.emit('sessionError', err); }, +- onFramed: pdu => { this.emit('incomingPdu', pdu); }, +- onPdu: pduObj => { this.dispatch(pduObj); }, +- onRefused: refused => { this.refuse(refused); }, +- onUnreadable: err => { +- this.emit('sessionError', err); +- this.teardown(); +- }, +- }, sock); ++ /** ++ * Takes no new request, waits up to `shutdownTimeout` for the message handlers still running and ++ * then for the requests already on the wire, and says what did not finish. ++ */ ++ private async drain(signal: AbortSignal | undefined): Promise { ++ this.link.stop(); ++ this.reconnectLoop?.stop(); ++ ++ // No bound link, so nothing is on the wire to wait out. ++ if (!this.outgoing.canCarry()) return {}; ++ ++ const deadline = this.shutdownTimeout > 0 ? Date.now() + this.shutdownTimeout : 0; ++ // Handlers first: answering a message can put a receipt on the wire; nothing on the wire produces a message. ++ const handlers = await this.incoming.drain(this.shutdownTimeout, 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 (!handlers.err) return requests; ++ if (!requests.err) return handlers; ++ ++ return { err: new Error(`${handlers.err.message}; ${requests.err.message}`) }; ++ } ++ ++ /** The session is over, drained or not: the link goes, and `close` fires once. */ ++ private finish(): void { ++ this.link.stop(); ++ this.reconnectLoop?.stop(); ++ this.dropLink(); ++ this.dlrMerger.clear(); ++ ++ if (this.link.end()) this.emit('close'); ++ } ++ ++ /** ++ * The one way a link goes: the socket closed, the stream could not be read, the peer went quiet, ++ * or a rebind failed. Retried where the loop is on, otherwise the end of the session. ++ */ ++ private linkLost(): void { ++ const lost = this.dropLink(); ++ ++ if (lost === 'disconnected') { ++ this.emit('disconnected'); ++ this.reconnectLoop?.schedule(); ++ } else if (lost === 'close') { ++ this.finish(); ++ } ++ } ++ ++ /** Takes the attached socket down and drops what only that link could settle. */ ++ private dropLink(): 'close' | 'disconnected' | undefined { ++ const lost = this.link.drop(); ++ ++ if (!lost) return undefined; ++ ++ this.outgoing.linkLost(); ++ this.timers.clear(); ++ this.incoming.clear(); ++ this.sock.destroy(); ++ ++ return lost; + } + + private loopFor(reconnect: ReconnectOptions | undefined): ReconnectLoop | undefined { +@@ -286,28 +334,26 @@ export class Session extends EventEmitter { + }); + } + +- private async comeBackUp( +- sock: Socket, +- bind: (session: Session) => Promise, +- ): Promise { +- this.attach(sock); ++ private async comeBackUp(sock: Socket, bind: (session: Session) => Promise): Promise { ++ this.transport.attach(sock); ++ this.link.attach(); + + const bound = await bind(this); + + if (bound.err) { +- this.teardown(); ++ this.linkLost(); + + return { err: bound.err }; + } + +- // close() can land while the rebind is in flight. +- if (!this.link.retrying()) { +- this.teardown(); ++ // close() can land while the rebind is in flight, and so can the peer's FIN. ++ if (!this.link.retrying() || !this.link.isAttached()) { ++ this.linkLost(); + +- return { err: new Error('Session closed while it was coming back up') }; ++ return { err: new Error('The link went while it was coming back up') }; + } + +- this.resetTimers(); ++ this.timers.reset(); + this.link.open(); + this.log.info('session - reconnected'); + this.emit('reconnected'); +@@ -315,84 +361,16 @@ export class Session extends EventEmitter { + 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 { +- 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); ++ private answer(built: Result<{ buffer: Buffer }>, cmdName: string, seqNr: number): VoidResult { ++ const sent = built.err ? { err: built.err } : this.transport.write(built.buffer); + +- // 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') }; ++ // 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); + } + +- 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; +- +- 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(); +- } +- +- private onData(chunk: Buffer): void { +- this.emit('data', chunk); +- this.resetTimers(); ++ return sent; + } + + private dispatch(pduObj: PduObject): void { +@@ -405,15 +383,11 @@ export class Session extends EventEmitter { + } + + this.emit('incomingPduObj', pduObj); +- // Every application hook and listener reached from an incoming PDU funnels through here. ++ // The application's onRequest hook is the one thing under here that can throw. + void this.incoming.handle(pduObj).catch((thrown: unknown) => { + const err = errorFrom(thrown); + +- this.log.error('session - a handler threw', { +- cmdName: pduObj.cmdName, +- message: err.message, +- seqNr: pduObj.seqNr, +- }); ++ this.log.error('session - a request hook threw', { cmdName: pduObj.cmdName, message: err.message, seqNr: pduObj.seqNr }); + this.emit('sessionError', err); + }); + } +@@ -433,21 +407,4 @@ export class Session extends EventEmitter { + + 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/src/sms.ts b/src/sms.ts +index 5149ceb..0f0f8f8 100644 +--- a/src/sms.ts ++++ b/src/sms.ts +@@ -1,5 +1,6 @@ + import type { ErrorName } from './defs/errors.ts'; + import type { MessageState } from './defs/constants.ts'; ++import type { ParamValue } from './defs/types.ts'; + import type { PduObject, PduObjectInput, TlvInputs } from './pdu.ts'; + import type { Result, VoidResult } from './result.ts'; + import type { Session } from './session.ts'; +@@ -46,9 +47,8 @@ export type Sms = { + /** Sends a delivery report back to the sender. Defaults to DELIVERED. */ + sendDlr: (status?: MessageState) => Promise; + /** +- * Answers the message, and says the application is done with it. A concatenated message was +- * answered segment by segment as it arrived, so there it only releases a shutdown's wait and +- * refuses an `smsId` or a refusing `status`. Part of the protocol, not optional. ++ * Answers the message, once. A concatenated message was answered segment by segment as it ++ * arrived, so there it writes nothing and refuses an `smsId` or a refusing `status`. + */ + sendResp: (options?: SendRespOptions) => Promise; + session: Session; +@@ -65,7 +65,11 @@ export type SmsInput = { + session: Session; + }; + ++/** What answering and receipting a message needs from the session it arrived on. */ + export type SmsHandlers = { ++ /** Writes one response now, on the link the request arrived on. */ ++ answer: (pduObj: PduObject, status: ErrorName, params: Record) => VoidResult; ++ /** The peer has been answered. */ + answered: () => void; + lostLink: () => boolean; + send: (input: PduObjectInput) => Promise>; +@@ -74,11 +78,13 @@ export type SmsHandlers = { + /** GSM 03.38 section 4 gives class 0 immediate display; every other class is stored somewhere. */ + const immediateDisplayClass = 0; + ++type Answer = { done: boolean; smsId: string }; ++ + export function createSms(input: SmsInput, handlers: SmsHandlers): Sms { + const first = input.pduObjs[0]; + const registered = first?.params.registered_delivery; + const dataCoding = first?.params.data_coding; +- const answered = { smsId: input.answeredAs ?? uuidv7() }; ++ const answer: Answer = { done: input.answeredAs !== undefined, smsId: input.answeredAs ?? uuidv7() }; + + const sms: Sms = { + answeredOnArrival: input.answeredAs !== undefined, +@@ -88,12 +94,12 @@ export function createSms(input: SmsInput, handlers: SmsHandlers): Sms { + message: decodeSegments(input.pduObjs), + pduObjs: input.pduObjs, + sendDlr: status => sendDlr(sms, input.session, handlers, status), +- sendResp: options => (input.answeredAs === undefined +- ? sendResp(sms, input.session, answered, options ?? {}, handlers) +- : answeredOnArrival(options ?? {}, handlers)), ++ sendResp: options => Promise.resolve(input.answeredAs === undefined ++ ? sendResp(sms, answer, options ?? {}, handlers) ++ : answeredOnArrival(options ?? {})), + session: input.session, + get smsId(): string { +- return answered.smsId; ++ return answer.smsId; + }, + submitTime: new Date(), + to: paramText(first?.params.destination_addr), +@@ -102,63 +108,52 @@ export function createSms(input: SmsInput, handlers: SmsHandlers): Sms { + return sms; + } + +-/** Every segment went out answered, so the call is what the shutdown waits for and nothing else. */ +-function answeredOnArrival( +- options: SendRespOptions, +- handlers: Pick, +-): Promise { ++/** Every segment went out answered, so there is nothing left to write and nothing to refuse. */ ++function answeredOnArrival(options: SendRespOptions): VoidResult { + if (options.smsId !== undefined) { +- return Promise.resolve({ +- err: new Error('This message\'s id was fixed when its first segment arrived; read sms.smsId'), +- }); ++ return { err: new Error('This message\'s id was fixed when its first segment arrived; read sms.smsId') }; + } + + if (options.status !== undefined && options.status !== 'ESME_ROK') { +- return Promise.resolve({ +- err: new Error('Its segments were answered as they arrived, so there is nothing left to refuse; refuse a segment from the onRequest option instead'), +- }); ++ return { err: new Error('Its segments were answered as they arrived, so there is nothing left to refuse; refuse a segment from the onRequest option instead') }; + } + +- handlers.answered(); +- +- return Promise.resolve({}); ++ return {}; + } + +-async function sendResp( ++function sendResp( + sms: Sms, +- session: Session, +- answered: { smsId: string }, ++ answer: Answer, + options: SendRespOptions, +- handlers: Pick, +-): Promise { ++ handlers: Pick, ++): VoidResult { + const total = sms.pduObjs.length; + +- if (total === 0) { +- return { err: new Error('No PDUs to answer') }; +- } +- +- if (options.smsId === '') { +- return { err: new Error('smsId must not be empty') }; +- } +- +- if (options.smsId !== undefined) answered.smsId = options.smsId; ++ if (answer.done) return { err: new Error('This message was already answered') }; ++ if (total === 0) return { err: new Error('No PDUs to answer') }; ++ if (options.smsId === '') return { err: new Error('smsId must not be empty') }; + + // A response carries the sequence number it was asked on, which the next link knows nothing about. + if (handlers.lostLink()) { + return { err: new Error('The link this message arrived on is gone, so nothing would correlate the response') }; + } + +- const results = await Promise.all(sms.pduObjs.map((pduObj, index) => session.sendReturn( ++ const smsId = options.smsId ?? answer.smsId; ++ const results = sms.pduObjs.map((pduObj, index) => handlers.answer( + pduObj, + options.status ?? 'ESME_ROK', +- respIdParams(pduObj.cmdName, segmentId(answered.smsId, index, total)), +- ))); +- ++ respIdParams(pduObj.cmdName, segmentId(smsId, index, total)), ++ )); + const failure = results.find(result => result.err); + +- if (!failure) handlers.answered(); ++ // Nothing reached the peer, so the message is still unanswered and the id it was given is not its. ++ if (failure) return failure; ++ ++ answer.done = true; ++ answer.smsId = smsId; ++ handlers.answered(); + +- return failure ?? {}; ++ return {}; + } + + /** The receipt as text, which is all of it a peer below SMPP 3.4 is allowed to be sent. */ +diff --git a/test/declared-alphabet.test.ts b/test/declared-alphabet.test.ts +index 8132883..44c18e8 100644 +--- a/test/declared-alphabet.test.ts ++++ b/test/declared-alphabet.test.ts +@@ -21,7 +21,7 @@ const bothTables = 'Cost 5$ @home'; + /** What SMPP 3.4 5.2.19 assigns each coding, read the way a peer honouring the field reads it. */ + const byTheSpecsTable: Record string> = { + // The SMSC default alphabet, which every peer in interop-tests/ runs as GSM 03.38. +- 0x00: octets => encodings.ASCII.decode(octets), ++ 0x00: octets => encodings.GSM.decode(octets), + // IA5 (CCITT T.50), whose whole range is what Latin-1 reads below 0x80. + [consts.ENCODING.IA5]: octets => octets.toString('latin1'), + }; +@@ -121,17 +121,18 @@ describe('the alphabet a message declares is the one its octets are written in', + + // sendDlr() writes its body as a string with no data_coding, so it takes the detected branch too. + test('declares 0x00 on a receipt it writes itself', async t => { +- const { err, server: smpp } = await server({ port: 0 }); ++ const { err, server: smpp } = await server({ ++ onSms: async sms => { ++ await sms.sendResp(); ++ await sms.sendDlr('DELIVERED'); ++ }, ++ port: 0, ++ }); + + assert.equal(err, undefined); + assert.ok(smpp); + closeAfter(t, smpp); + +- smpp.on('session', peer => peer.on('sms', async sms => { +- await sms.sendResp(); +- await sms.sendDlr('DELIVERED'); +- })); +- + const connected = await client({ port: smpp.port, reconnect: false }); + + assert.equal(connected.err, undefined); +@@ -166,7 +167,7 @@ describe('the alphabet a message declares is the one its octets are written in', + + describe('what a peer declares is read as generously as it was before', () => { + test('reads data_coding 0x01 as GSM 03.38, as 0x00 is read', () => { +- assert.equal(encodingByDataCoding(0x01), 'ASCII'); ++ assert.equal(encodingByDataCoding(0x01), 'GSM'); + + const octets = Buffer.from('436f73742035022000686f6d65', 'hex'); + +diff --git a/test/encodings.test.ts b/test/encodings.test.ts +index 67fc68c..c2abb0f 100644 +--- a/test/encodings.test.ts ++++ b/test/encodings.test.ts +@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; + import test, { describe } from 'node:test'; + import { dataCodingByEncoding, detect, encodingByDataCoding, encodings, isEncodingName, unencodable } from '../src/defs/encodings.ts'; + +-describe('ASCII (GSM 03.38)', () => { ++describe('GSM (GSM 03.38)', () => { + const samples: [string, number[]][] = [ + ['@£$¥', [0, 1, 2, 3]], + [' 1a=', [0x20, 0x31, 0x61, 0x3D]], +@@ -10,23 +10,23 @@ describe('ASCII (GSM 03.38)', () => { + ]; + + test('matches strings encodable in the GSM 03.38 charset', () => { +- assert.ok(encodings.ASCII.match('')); +- assert.ok(encodings.ASCII.match('@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ\x1BÆæßÉ !"#¤%&\'')); +- assert.ok(encodings.ASCII.match('()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZ')); +- assert.ok(encodings.ASCII.match('ÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà')); +- assert.ok(encodings.ASCII.match('\f^{}\\[~]|€')); ++ assert.ok(encodings.GSM.match('')); ++ assert.ok(encodings.GSM.match('@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ\x1BÆæßÉ !"#¤%&\'')); ++ assert.ok(encodings.GSM.match('()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZ')); ++ assert.ok(encodings.GSM.match('ÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà')); ++ assert.ok(encodings.GSM.match('\f^{}\\[~]|€')); + }); + + test('rejects strings outside the GSM 03.38 charset', () => { +- assert.ok(!encodings.ASCII.match('`')); +- assert.ok(!encodings.ASCII.match('ÁáçÚUÓO')); +- assert.ok(!encodings.ASCII.match('تست')); ++ assert.ok(!encodings.GSM.match('`')); ++ assert.ok(!encodings.GSM.match('ÁáçÚUÓO')); ++ assert.ok(!encodings.GSM.match('تست')); + }); + + test('round-trips the sample strings', () => { + for (const [str, bytes] of samples) { +- assert.deepEqual(encodings.ASCII.encode(str), Buffer.from(bytes)); +- assert.equal(encodings.ASCII.decode(Buffer.from(bytes)), str); ++ assert.deepEqual(encodings.GSM.encode(str), Buffer.from(bytes)); ++ assert.equal(encodings.GSM.decode(Buffer.from(bytes)), str); + } + }); + }); +@@ -97,8 +97,8 @@ describe('UCS2', () => { + + describe('detect()', () => { + test('picks the narrowest encoding that fits the string', () => { +- assert.equal(detect(''), 'ASCII'); +- assert.equal(detect('ÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà(){}[]'), 'ASCII'); ++ assert.equal(detect(''), 'GSM'); ++ assert.equal(detect('ÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà(){}[]'), 'GSM'); + assert.equal(detect('`ÁáçÚUÓO'), 'UCS2'); + assert.equal(detect('«©®µ¶±»'), 'UCS2'); + assert.equal(detect('ʹʺʻʼʽ`'), 'UCS2'); +@@ -122,16 +122,16 @@ describe('detect()', () => { + describe('unencodable()', () => { + test('names the first character an alphabet cannot carry, and nothing where it carries them all', () => { + assert.deepEqual(unencodable('あいう', 'LATIN1'), { char: 'あ', index: 0 }); +- assert.deepEqual(unencodable('Åsa naïve', 'ASCII'), { char: 'ï', index: 6 }); +- assert.equal(unencodable('€{}[]\\~^|\f', 'ASCII'), undefined); +- assert.equal(unencodable('Åsa', 'ASCII'), undefined); ++ assert.deepEqual(unencodable('Åsa naïve', 'GSM'), { char: 'ï', index: 6 }); ++ assert.equal(unencodable('€{}[]\\~^|\f', 'GSM'), undefined); ++ assert.equal(unencodable('Åsa', 'GSM'), undefined); + assert.equal(unencodable('`ÁáçÚ', 'LATIN1'), undefined); + assert.equal(unencodable('あいう😀', 'UCS2'), undefined); + }); + + test('counts the index in the units the message is written in, so a surrogate pair reads back whole', () => { + assert.deepEqual(unencodable('ab😀', 'LATIN1'), { char: '😀', index: 2 }); +- assert.deepEqual(unencodable('a😀b', 'ASCII'), { char: '😀', index: 1 }); ++ assert.deepEqual(unencodable('a😀b', 'GSM'), { char: '😀', index: 1 }); + }); + + test('carries every octet through Latin-1, which is what an 8-bit binary body is sent as', () => { +@@ -147,36 +147,36 @@ describe('unencodable()', () => { + }); + + test('reads a character at a time, so a bare GSM escape beside its base reads as carried', () => { +- assert.equal(unencodable('\x1Be', 'ASCII'), undefined); +- assert.deepEqual(encodings.ASCII.encode('\x1Be'), encodings.ASCII.encode('€')); ++ assert.equal(unencodable('\x1Be', 'GSM'), undefined); ++ assert.deepEqual(encodings.GSM.encode('\x1Be'), encodings.GSM.encode('€')); + }); + }); + + describe('encodingByDataCoding()', () => { + test('resolves the flat SMPP data_coding table', () => { +- assert.equal(encodingByDataCoding(0x00), 'ASCII'); +- assert.equal(encodingByDataCoding(0x01), 'ASCII'); ++ assert.equal(encodingByDataCoding(0x00), 'GSM'); ++ assert.equal(encodingByDataCoding(0x01), 'GSM'); + assert.equal(encodingByDataCoding(0x08), 'UCS2'); + }); + + // 0.4.0 resolved 0x03 to the alias ISO_8859_1, which has no decoder, and silently fell back to +- // ASCII — every Latin-1 message came out corrupted. +- test('resolves 0x03 to LATIN1 rather than falling back to ASCII', () => { ++ // GSM — every Latin-1 message came out corrupted. ++ test('resolves 0x03 to LATIN1 rather than falling back to GSM', () => { + assert.equal(encodingByDataCoding(0x03), 'LATIN1'); + }); + + test('reads the alphabet bits when a message class is present', () => { +- assert.equal(encodingByDataCoding(0x10), 'ASCII'); +- assert.equal(encodingByDataCoding(0x11), 'ASCII'); ++ assert.equal(encodingByDataCoding(0x10), 'GSM'); ++ assert.equal(encodingByDataCoding(0x11), 'GSM'); + assert.equal(encodingByDataCoding(0x18), 'UCS2'); + assert.equal(encodingByDataCoding(0x1A), 'UCS2'); +- assert.equal(encodingByDataCoding(0xF0), 'ASCII'); +- assert.equal(encodingByDataCoding(0xF1), 'ASCII'); ++ assert.equal(encodingByDataCoding(0xF0), 'GSM'); ++ assert.equal(encodingByDataCoding(0xF1), 'GSM'); + }); + + // The compressed and automatic-deletion groups put the alphabet where the plain one does. + test('reads them in the compressed and automatic-deletion groups too', () => { +- assert.equal(encodingByDataCoding(0x30), 'ASCII'); ++ assert.equal(encodingByDataCoding(0x30), 'GSM'); + assert.equal(encodingByDataCoding(0x38), 'UCS2'); + assert.equal(encodingByDataCoding(0x54), 'LATIN1'); + assert.equal(encodingByDataCoding(0x58), 'UCS2'); +@@ -196,10 +196,10 @@ describe('encodingByDataCoding()', () => { + } + }); + +- test('falls back to ASCII for alphabets it has no codec for', () => { +- assert.equal(encodingByDataCoding(0x05), 'ASCII'); +- assert.equal(encodingByDataCoding(0x0E), 'ASCII'); ++ test('falls back to GSM for alphabets it has no codec for', () => { ++ assert.equal(encodingByDataCoding(0x05), 'GSM'); ++ assert.equal(encodingByDataCoding(0x0E), 'GSM'); + // No class, so nothing says the octet is spelled 03.38 rather than SMPP's own flat table. +- assert.equal(encodingByDataCoding(0x48), 'ASCII'); ++ assert.equal(encodingByDataCoding(0x48), 'GSM'); + }); + }); +diff --git a/test/interop.test.ts b/test/interop.test.ts +index 2e5d905..f17902f 100644 +--- a/test/interop.test.ts ++++ b/test/interop.test.ts +@@ -249,16 +249,14 @@ describe('a live session against the reference implementation', () => { + }); + + test('a reference client binds to our server and delivers an SMS', async t => { +- const { err: serverErr, server: smpp } = await server({ port: 0 }); ++ let deliver: (sms: Sms) => void = () => undefined; ++ const incoming = new Promise(resolve => { deliver = resolve; }); ++ const { err: serverErr, server: smpp } = await server({ onSms: sms => { deliver(sms); }, port: 0 }); + + assert.equal(serverErr, undefined); + assert.ok(smpp); + closeAfter(t, smpp); + +- const incoming = new Promise(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); +- + const refSession = reference.connect({ + url: `smpp://localhost:${String(smpp.port)}`, + }); +diff --git a/test/message-class.test.ts b/test/message-class.test.ts +index e613139..4718705 100644 +--- a/test/message-class.test.ts ++++ b/test/message-class.test.ts +@@ -27,18 +27,19 @@ type MessagePeer = { + /** A server that answers every message, and a client to write raw submit_sm PDUs at it. */ + async function messagesInto(t: TestContext): Promise { + const received: Sms[] = []; +- const { err, server: smpp } = await server({ port: 0 }); ++ const { err, server: smpp } = await server({ ++ onSms: sms => { ++ received.push(sms); ++ ++ return sms.sendResp(); ++ }, ++ port: 0, ++ }); + + assert.equal(err, undefined); + assert.ok(smpp); + closeAfter(t, smpp); + +- smpp.on('session', peer => peer.on('sms', sms => { +- received.push(sms); +- +- return sms.sendResp(); +- })); +- + const connected = await client({ port: smpp.port, reconnect: false }); + + assert.equal(connected.err, undefined); +@@ -216,7 +217,7 @@ describe('sendSms() flash', () => { + const sent = await submitSms(deps, { encoding, from, message: 'Hello world', to }); + + assert.ok(sent.err instanceof Error, JSON.stringify(encoding)); +- assert.match(sent.err.message, /encoding must be ASCII, LATIN1, UCS2/); ++ assert.match(sent.err.message, /encoding must be GSM, LATIN1, UCS2/); + assert.deepEqual(sent.smsIds, []); + } + +diff --git a/test/message.test.ts b/test/message.test.ts +index 37392a4..356a525 100644 +--- a/test/message.test.ts ++++ b/test/message.test.ts +@@ -124,7 +124,7 @@ describe('splitMessage()', () => { + + describe('encodeMessage() and decodeMessage()', () => { + test('picks GSM for GSM-safe text and UCS2 otherwise', () => { +- assert.equal(encodeMessage('Hello').encoding, 'ASCII'); ++ assert.equal(encodeMessage('Hello').encoding, 'GSM'); + assert.equal(encodeMessage('تست').encoding, 'UCS2'); + }); + +@@ -133,8 +133,8 @@ describe('encodeMessage() and decodeMessage()', () => { + }); + + // 0.4.0 resolved data_coding 0x03 to the alias ISO_8859_1, which has no decoder, so every +- // Latin-1 message was silently decoded as ASCII. +- test('decodes Latin-1 rather than falling back to ASCII', () => { ++ // Latin-1 message was silently decoded as GSM 03.38. ++ test('decodes Latin-1 rather than falling back to GSM', () => { + assert.equal(decodeMessage(Buffer.from([0xE1, 0xE7, 0xDA]), 0x03).message, 'áçÚ'); + }); + +@@ -165,7 +165,7 @@ describe('encodeMessage() and decodeMessage()', () => { + }); + + describe('the alphabet the encoding helpers are asked for', () => { +- const everyName: EncodingName[] = ['ASCII', 'LATIN1', 'UCS2']; ++ const everyName: EncodingName[] = ['GSM', 'LATIN1', 'UCS2']; + + test('is one of three, each with a codec, so none of the three helpers can reach an absent one', () => { + assert.deepEqual(Object.keys(encodings).sort(), [...everyName].sort()); +diff --git a/test/readme.test.ts b/test/readme.test.ts +index f2198a5..ae39ec8 100644 +--- a/test/readme.test.ts ++++ b/test/readme.test.ts +@@ -26,21 +26,20 @@ function once(register: (resolve: (value: T) => void) => void): Promise { + } + + /** The README's examples listen on the documented default port, so one server runs at a time. */ +-async function answeringServer(t: TestContext): Promise { +- const { err, server: smpp } = await server(); +- +- assert.equal(err, undefined); +- assert.ok(smpp); +- closeAfter(t, smpp); +- +- smpp.on('session', session => { +- session.on('sms', async sms => { ++async function answeringServer(t: TestContext, seen?: (sms: Sms) => void): Promise { ++ const { err, server: smpp } = await server({ ++ onSms: async sms => { ++ seen?.(sms); + await sms.sendResp(); + + if (sms.dlr) await sms.sendDlr(); +- }); ++ }, + }); + ++ assert.equal(err, undefined); ++ assert.ok(smpp); ++ closeAfter(t, smpp); ++ + return smpp; + } + +@@ -121,10 +120,11 @@ describe('README: Client', () => { + }); + + test('the documented sending options', async t => { +- const smpp = await answeringServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); ++ let deliver: (sms: Sms) => void = () => undefined; ++ const incoming = once(resolve => { deliver = resolve; }); ++ ++ await answeringServer(t, sms => { deliver(sms); }); ++ + const { err, session } = await client(); + if (err) throw err; + +@@ -154,12 +154,19 @@ describe('README: Client', () => { + test('receiving an inbound message on a client session', async t => { + const smpp = await answeringServer(t); + const bound = once(resolve => { smpp.on('session', resolve); }); +- const { err, session } = await client(); ++ let deliver: (sms: Sms) => void = () => undefined; ++ const incoming = once(resolve => { deliver = resolve; }); ++ const { err, session } = await client({ ++ onSms: async sms => { ++ // sms.from, sms.to, sms.message ++ await sms.sendResp(); ++ deliver(sms); ++ }, ++ }); + if (err) throw err; + + closeAfter(t, session); + +- const incoming = once(resolve => { session.on('sms', resolve); }); + const peer = await bound; + + void peer.send({ +@@ -173,27 +180,22 @@ describe('README: Client', () => { + + const sms = await incoming; + +- await sms.sendResp(); +- + assert.equal(sms.message, 'inbound hello'); + }); + }); + + describe('README: Server', () => { + test('the simplest possible server', async t => { +- const { err, server: smpp } = await server(); +- if (err) throw err; +- +- closeAfter(t, smpp); +- + const received: string[] = []; +- +- smpp.on('session', session => { +- session.on('sms', async sms => { ++ const { err, server: smpp } = await server({ ++ onSms: async sms => { + received.push(sms.message); + await sms.sendResp(); +- }); ++ }, + }); ++ if (err) throw err; ++ ++ closeAfter(t, smpp); + + const { err: clientErr, session } = await client(); + +@@ -208,25 +210,20 @@ describe('README: Server', () => { + }); + + test('with authentication and delivery reports', async t => { ++ let answeredOnArrival: boolean | undefined; ++ let userData: unknown; + const { err, server: smpp } = await server({ + authenticate: ({ password, systemId }) => { + if (systemId !== 'foo' || password !== 'bar') return false; + + return { userData: { userId: 123 } }; + }, +- }); +- if (err) throw err; +- +- closeAfter(t, smpp); +- +- let answeredOnArrival: boolean | undefined; +- +- smpp.on('session', session => { +- session.on('sms', async sms => { ++ onSms: async sms => { ++ userData = sms.session.userData; + answeredOnArrival = sms.answeredOnArrival; + + if (sms.answeredOnArrival) { +- await sms.sendResp(); // multipart: only releases the shutdown drain ++ await sms.sendResp(); // multipart: already answered per segment, so this writes nothing + } else { + await sms.sendResp(); // ESME_ROK with a generated id + } +@@ -234,8 +231,11 @@ describe('README: Server', () => { + if (sms.dlr) { + await sms.sendDlr(); + } +- }); ++ }, + }); ++ if (err) throw err; ++ ++ closeAfter(t, smpp); + + assert.equal(smpp.port, 2775); + +@@ -260,10 +260,12 @@ describe('README: Server', () => { + assert.equal(sent.err, undefined); + assert.equal((await reported).statusMsg, 'DELIVERED'); + assert.equal(answeredOnArrival, false); ++ assert.deepEqual(userData, { userId: 123 }); + }); + + test('refusing a segment at onRequest, before this library would answer it', async t => { + const knownRecipients = new Set(['46709771337']); ++ let messages = 0; + const { err, server: smpp } = await server({ + onRequest: async (session, pduObj) => { + if (!isCommand(pduObj, 'submit_sm') || knownRecipients.has(pduObj.params.destination_addr)) { +@@ -274,15 +276,12 @@ describe('README: Server', () => { + + return true; + }, ++ onSms: () => { messages++; }, + }); + if (err) throw err; + + closeAfter(t, smpp); + +- let messages = 0; +- +- smpp.on('session', bound => bound.on('sms', () => { messages++; })); +- + const connected = await client(); + if (connected.err) throw connected.err; + +diff --git a/test/session-error.test.ts b/test/session-error.test.ts +index af9b3b9..7cc97e1 100644 +--- a/test/session-error.test.ts ++++ b/test/session-error.test.ts +@@ -38,8 +38,12 @@ async function waitFor(condition: () => boolean, budget = 2000): Promise[0] = {}) { +- const { err, server: smpp } = await server({ port: 0 }); ++async function linked( ++ t: TestContext, ++ options: Parameters[0] = {}, ++ serverOptions: Parameters[0] = {}, ++) { ++ const { err, server: smpp } = await server({ ...serverOptions, port: 0 }); + + assert.equal(err, undefined); + assert.ok(smpp); +@@ -94,19 +98,19 @@ describe('telling a refused PDU from a failed session', () => { + ); + }); + +- test('reports a listener that threw as an error that is no refusal', async t => { +- const { peer, session } = await linked(t, { responseTimeout: 200 }); ++ test('reports a handler that threw as an error that is no refusal', async t => { ++ const { peer, session } = await linked(t, { responseTimeout: 200 }, { ++ onSms: () => { throw new Error('handler exploded'); }, ++ }); + const failed = once(resolve => { peer.on('sessionError', resolve); }); + +- peer.on('sms', () => { throw new Error('listener exploded'); }); +- +- await session.sendSms({ from: '46701113311', message: 'blows the listener up', to: '46709771337' }); ++ await session.sendSms({ from: '46701113311', message: 'blows the handler up', to: '46709771337' }); + + const reported = await raceWithin(2000, failed); + +- assert.ok(reported, 'the listener that threw never reached the session'); ++ assert.ok(reported, 'the handler that threw never reached the session'); + assert.ok(!(reported instanceof PduRefusedError), 'a session failure is not a refused PDU'); +- assert.equal(reported.message, 'listener exploded'); ++ assert.equal(reported.message, 'handler exploded'); + }); + + test('reports a socket the peer reset as an error that is no refusal', async t => { +diff --git a/test/session-extras.test.ts b/test/session-extras.test.ts +index 6b36279..16f9c74 100644 +--- a/test/session-extras.test.ts ++++ b/test/session-extras.test.ts +@@ -5,9 +5,9 @@ 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 { HandledMessagesOptions, SmsRoute } from '../src/handled-messages.ts'; + import type { MessageState } from '../src/defs/constants.ts'; +-import type { MessageDlr } from '../src/session.ts'; ++import type { MessageDlr, OnSms } from '../src/session.ts'; + import type { PduObject, PduObjectInput } from '../src/pdu.ts'; + import type { Result } from '../src/result.ts'; + import type { SendSmsResult } from '../src/send-sms.ts'; +@@ -15,7 +15,7 @@ import type { SmppLog } from '../src/log.ts'; + import type { Sms } from '../src/sms.ts'; + import type { SmppServer } from '../src/server.ts'; + import type { TestContext } from 'node:test'; +-import { HeldMessages } from '../src/held-messages.ts'; ++import { HandledMessages } from '../src/handled-messages.ts'; + import { IncomingRequests, refusedSegmentStatus } from '../src/incoming-requests.ts'; + import { UnansweredError } from '../src/unanswered-error.ts'; + import { createSms } from '../src/sms.ts'; +@@ -26,7 +26,9 @@ 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 } from '../src/session-options.ts'; ++import { defaults } from '../src/defaults.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'; +@@ -86,6 +88,16 @@ function within(ms: number, promise: Promise): Promise { + return Promise.race([promise, delay(ms).then((): undefined => undefined)]); + } + ++type Inbox = { onSms: (sms: Sms) => void; sms: Promise }; ++ ++/** Hands the test the first message and returns at once, so nothing is held. */ ++function inbox(): Inbox { ++ let deliver: (sms: Sms) => void = () => undefined; ++ const sms = once(resolve => { deliver = resolve; }); ++ ++ return { onSms: message => { deliver(message); }, sms }; ++} ++ + /** A client still retrying holds a socket and a timer nothing else releases. */ + function abortAfter( + t: TestContext, +@@ -103,15 +115,34 @@ function abortAfter( + + function incomingOn(session: Session, options: Partial = {}): IncomingRequests { + return new IncomingRequests({ ++ // Through the session, so a test that stubs sendReturn() sees every answer. ++ answer: (pduObj, status, params) => { void session.sendReturn(pduObj, status, params); return {}; }, + 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') }), ++ sendReceipt: () => Promise.resolve({ err: new Error('never sent') }), + session, + ...options, + }); + } + ++/** A handler that returns only when the test lets it, so the message stays handled until then. */ ++function holding(): { onSms: OnSms; release: () => void } { ++ const released = latch(); ++ ++ return { onSms: () => released.passed, release: released.open }; ++} ++ ++/** A handler that never returns: what an application stuck on a message looks like. */ ++const stuck: OnSms = () => new Promise(() => undefined); ++ ++/** How a message reaches the session it is offered on, with nothing on the wire. */ ++const noRoute: SmsRoute = { ++ answer: () => ({}), ++ lostLink: () => false, ++ send: () => Promise.resolve({ err: new Error('never sent') }), ++}; ++ + function submitPdu(seqNr: number, cmdStatus: ErrorName = 'ESME_ROK'): PduObject { + return { + cmdId: 0x00000004, +@@ -170,10 +201,8 @@ function latch(): Latch { + describe('merged delivery reports', () => { + // 0.4.0 allocated a longSmsDlrs store to do exactly this and then never used it. + test('reports once on a whole multipart message', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); ++ const incoming = inbox(); ++ const smpp = await startServer(t, { onSms: incoming.onSms }); + const { session } = await connect(t, smpp); + + assert.ok(session); +@@ -184,7 +213,7 @@ describe('merged delivery reports', () => { + session.on('dlr', dlr => perSegment.push(dlr.smsId ?? '')); + + const [sms] = await Promise.all([ +- incoming.then(async received => { ++ incoming.sms.then(async received => { + await received.sendResp(); + + return received; +@@ -208,10 +237,8 @@ describe('merged delivery reports', () => { + }); + + test('reports once, on the final receipts, when the peer reports en route first', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); ++ const incoming = inbox(); ++ const smpp = await startServer(t, { onSms: incoming.onSms }); + const { session } = await connect(t, smpp); + + assert.ok(session); +@@ -226,7 +253,7 @@ describe('merged delivery reports', () => { + }); + + const [sms] = await Promise.all([ +- incoming.then(async received => { ++ incoming.sms.then(async received => { + await received.sendResp(); + + return received; +@@ -264,10 +291,8 @@ describe('merged delivery reports', () => { + }); + + test('reports the worst status across the segments', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); ++ const incoming = inbox(); ++ const smpp = await startServer(t, { onSms: incoming.onSms }); + const { session } = await connect(t, smpp); + + assert.ok(session); +@@ -275,7 +300,7 @@ describe('merged delivery reports', () => { + const merged = once(resolve => { session.on('messageDlr', resolve); }); + + const [sms] = await Promise.all([ +- incoming.then(async received => { ++ incoming.sms.then(async received => { + await received.sendResp(); + + return received; +@@ -378,16 +403,14 @@ describe('sendSms()', () => { + } + + test('reports a submit_sm the peer refused instead of an empty message id', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); ++ const incoming = inbox(); ++ const smpp = await startServer(t, { onSms: incoming.onSms }); + const { session } = await connect(t, smpp); + + assert.ok(session); + + const [, sent] = await Promise.all([ +- incoming.then(received => received.sendResp({ status: 'ESME_RMSGQFUL' })), ++ incoming.sms.then(received => received.sendResp({ status: 'ESME_RMSGQFUL' })), + session.sendSms({ from: '46701113311', message: 'the queue is full', to: '46709771337' }), + ]); + +@@ -602,15 +625,13 @@ describe('reconnect', () => { + }); + + test('re-binds after the connection drops, keeping the same session object', async t => { +- const smpp = await startServer(t); + const messages: string[] = []; +- +- // Registered up front so the session created by the reconnect is covered too. +- smpp.on('session', bound => { +- bound.on('sms', sms => { ++ const smpp = await startServer(t, { ++ onSms: sms => { + messages.push(sms.message); +- void sms.sendResp(); +- }); ++ ++ return sms.sendResp(); ++ }, + }); + + const { err, session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } }); +@@ -652,12 +673,13 @@ describe('reconnect', () => { + }); + + test('merges the receipts of a multipart message across a drop', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', bound => bound.on('sms', sms => { +- resolve(sms); +- void sms.sendResp(); +- })); ++ const incoming = inbox(); ++ const smpp = await startServer(t, { ++ onSms: sms => { ++ incoming.onSms(sms); ++ ++ return sms.sendResp(); ++ }, + }); + const { session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } }); + +@@ -669,7 +691,7 @@ describe('reconnect', () => { + message: 'x'.repeat(400), + to: '46709771337', + }); +- const sms = await incoming; ++ const sms = await incoming.sms; + + assert.deepEqual(sent.smsIds, [1, 2, 3].map(part => `${sms.smsId}-${String(part)}`)); + +@@ -694,19 +716,19 @@ describe('reconnect', () => { + + test('refuses to answer a message whose link went, held or already answered', async t => { + const smpp = await startServer(t); +- const { session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } }); +- +- assert.ok(session); +- + const arrived: Sms[] = []; +- const both = once(resolve => { +- session.on('sms', sms => { ++ const both = latch(); ++ const { session } = await connect(t, smpp, { ++ onSms: sms => { + arrived.push(sms); + +- if (arrived.length === 2) resolve(true); +- }); ++ if (arrived.length === 2) both.open(); ++ }, ++ reconnect: { maxDelay: 100, minDelay: 20 }, + }); + ++ assert.ok(session); ++ + for (const text of ['answered before the drop', 'never answered']) { + void peerOf(smpp).send({ + cmdName: 'deliver_sm', +@@ -718,7 +740,7 @@ describe('reconnect', () => { + }); + } + +- await both; ++ await both.passed; + + const [answered, held] = arrived; + +@@ -737,7 +759,7 @@ describe('reconnect', () => { + // A response is dispatched before `incomingPduObj`, so only the raw event sees one arrive. + peerOf(smpp).on('incomingPdu', () => { taken++; }); + +- assert.match((await answered.sendResp()).err?.message ?? '', /link this message arrived on is gone/); ++ assert.match((await answered.sendResp()).err?.message ?? '', /already answered/); + assert.match((await held.sendResp()).err?.message ?? '', /link this message arrived on is gone/); + + assert.equal((await held.sendDlr('DELIVERED')).err, undefined); +@@ -750,10 +772,12 @@ describe('reconnect', () => { + closeAfter(t, session); + + const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 100 }); +- const incoming = incomingOn(session, { link, onRequest: async () => { await delay(10); return false; } }); + let messages = 0; +- +- session.on('sms', () => { messages++; }); ++ const incoming = incomingOn(session, { ++ link, ++ onRequest: async () => { await delay(10); return false; }, ++ onSms: () => { messages++; }, ++ }); + + const handled = incoming.handle(submitPdu(1)); + +@@ -1148,27 +1172,23 @@ describe('connectTimeout', () => { + + describe('sends across a reconnect', () => { + /** Answers every message after the first, which is left to hold the send window open. */ +- function answerAfterTheFirst(smpp: SmppServer, arrived: string[]): Latch { ++ function answerAfterTheFirst(arrived: string[]): { first: Latch; onSms: OnSms } { + const first = latch(); + +- smpp.on('session', peer => { +- peer.on('sms', async sms => { ++ return { ++ first, ++ onSms: async sms => { + arrived.push(sms.message); + + if (arrived.length === 1) first.open(); + else await sms.sendResp(); +- }); +- }); +- +- return first; ++ }, ++ }; + } + + test('holds a send issued while the link is down and puts it on the new link', async t => { +- const smpp = await startServer(t); + const arrived: string[] = []; +- +- smpp.on('session', peer => { peer.on('sms', async sms => { arrived.push(sms.message); await sms.sendResp(); }); }); +- ++ const smpp = await startServer(t, { onSms: async sms => { arrived.push(sms.message); await sms.sendResp(); } }); + const { session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } }); + + assert.ok(session); +@@ -1187,9 +1207,9 @@ describe('sends across a reconnect', () => { + }); + + test('puts a segment still queued behind a full window on the new link', async t => { +- const smpp = await startServer(t); + const arrived: string[] = []; +- const first = answerAfterTheFirst(smpp, arrived); ++ const { first, onSms } = answerAfterTheFirst(arrived); ++ const smpp = await startServer(t, { onSms }); + const { session } = await connect(t, smpp, { + maxOutstanding: 1, + reconnect: { maxDelay: 100, minDelay: 20 }, +@@ -1228,10 +1248,8 @@ describe('sends across a reconnect', () => { + + return true; + }, ++ onSms: sms => sms.sendResp(), + }); +- +- smpp.on('session', peer => { peer.on('sms', async sms => { await sms.sendResp(); }); }); +- + const { session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } }); + + assert.ok(session); +@@ -1258,10 +1276,7 @@ describe('sends across a reconnect', () => { + }); + + test('counts a segment the peer never answered in time as unanswered', async t => { +- const smpp = await startServer(t); +- +- smpp.on('session', peer => { peer.on('sms', () => undefined); }); +- ++ const smpp = await startServer(t, { onSms: () => undefined }); + const { session } = await connect(t, smpp, { responseTimeout: 200 }); + + assert.ok(session); +@@ -1273,8 +1288,8 @@ describe('sends across a reconnect', () => { + }); + + test('counts a segment aborted after it went out as unanswered', async t => { +- const smpp = await startServer(t); +- const arrived = once(resolve => { smpp.on('session', peer => peer.on('sms', resolve)); }); ++ const arrived = inbox(); ++ const smpp = await startServer(t, { onSms: arrived.onSms }); + const { session } = await connect(t, smpp); + + assert.ok(session); +@@ -1285,7 +1300,7 @@ describe('sends across a reconnect', () => { + { signal: controller.signal }, + ); + +- await arrived; ++ await arrived.sms; + controller.abort(); + + const sent = await sending; +@@ -1295,15 +1310,15 @@ describe('sends across a reconnect', () => { + }); + + test('reports a segment the link dropped under as unanswered, not as never sent', async t => { +- const smpp = await startServer(t); +- const arrived = once(resolve => { smpp.on('session', peer => peer.on('sms', resolve)); }); ++ const arrived = inbox(); ++ const smpp = await startServer(t, { onSms: arrived.onSms }); + const { session } = await connect(t, smpp, { reconnect: false }); + + assert.ok(session); + + const sending = session.sendSms({ from: '46701113311', message: 'in flight', to: '46709771337' }); + +- await arrived; ++ await arrived.sms; + peerOf(smpp).sock.destroy(); + + const sent = await sending; +@@ -1536,125 +1551,126 @@ describe('SendWindow', () => { + }); + }); + +-// Goal 4: an application that answers nothing must not grow this for the life of the link. +-describe('held message bounds', () => { +- function message(seqNr: number): PduObject[] { +- return [submitPdu(seqNr)]; +- } +- +- function offer(held: HeldMessages, seqNr: number): MessageHold { +- const hold = held.offer(message(seqNr)); +- +- assert.ok(hold); +- +- return hold; +- } ++// Goal 4: an application whose handlers never return must not grow this for the life of the link. ++describe('handled message bounds', () => { ++ type Offered = { release: () => void; sms: Sms }; + +- /** Offers to a session with a listener, so an offer is held rather than released as untaken. */ +- function heldOn( ++ function handledOn( + t: TestContext, +- options: Pick, +- ): HeldMessages { ++ options: Pick, ++ ): { handled: HandledMessages; offer: (seqNr: number) => Offered } { + const session = new Session({ sock: new net.Socket() }); +- +- closeAfter(t, session); +- session.on('sms', () => undefined); +- +- return new HeldMessages({ ++ const releases = new Map void>(); ++ const handled = new HandledMessages({ + ...options, +- link: new LinkLife({ log: silentLog, reconnects: false, timeout: 100 }), + log: silentLog, +- sendPastDrain: () => Promise.resolve({ err: new Error('never sent') }), +- session, ++ onSms: sms => new Promise(resolve => { releases.set(sms, resolve); }), ++ report: () => undefined, + }); ++ ++ closeAfter(t, session); ++ ++ return { ++ handled, ++ offer: seqNr => { ++ const sms = handled.offer({ pduObjs: [submitPdu(seqNr)], session }, noRoute, 'ESME_RTHROTTLED'); ++ ++ return { release: () => { releases.get(sms)?.(); }, sms }; ++ }, ++ }; + } + +- test('is full at its count, and a re-used sequence number replaces rather than adding', t => { +- const held = heldOn(t, { max: 2, maxOctets: 1_000_000, timeout: 10_000 }); +- const first = offer(held, 1); +- const replaced = offer(held, 2); ++ function settled(): Promise { ++ return new Promise(resolve => { setImmediate(resolve); }); ++ } + +- offer(held, 2); ++ test('is full at its count until a handler returns', async t => { ++ const { handled, offer } = handledOn(t, { max: 2, maxOctets: 1_000_000, timeout: 10_000 }); ++ const first = offer(1); + +- 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); ++ offer(2); + +- held.clear(); ++ assert.equal(handled.size, 2); ++ assert.equal(handled.octets, 2 * 1026, 'each message weighs its object and its three text fields'); ++ assert.equal(handled.refuses(), true); ++ ++ first.release(); ++ await settled(); ++ ++ assert.equal(handled.size, 1); ++ assert.equal(handled.refuses(), false); ++ handled.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); ++ const { handled, offer } = handledOn(t, { max: 10, maxOctets: 2000, now: () => now, timeout: 10_000 }); ++ const returned = offer(1); + +- assert.equal(held.full(), false); +- offer(held, 2); +- assert.equal(held.full(), true); ++ assert.equal(handled.refuses(), false); ++ offer(2); ++ assert.equal(handled.refuses(), true); + +- answered.release(); +- assert.equal(held.full(), false, 'after a release'); +- offer(held, 3); ++ returned.release(); ++ await settled(); ++ assert.equal(handled.refuses(), false, 'after the handler returned'); ++ offer(3); + + now = 20_000; +- held.sweep(); ++ handled.sweep(); + now = 0; +- assert.equal(held.full(), false, 'after a sweep'); +- offer(held, 4); +- offer(held, 5); ++ assert.equal(handled.refuses(), false, 'after a sweep'); ++ offer(4); ++ offer(5); + +- held.clear(); +- assert.equal(held.full(), false, 'after a clear'); ++ handled.clear(); ++ assert.equal(handled.refuses(), false, 'after a clear'); + }); + +- // Dropping one the application still holds frees nothing, and the drain stops waiting for it. ++ // Dropping one the handler still holds frees nothing, and the drain stops waiting for it. + test('refuses what arrives past the bound with a status that asks the peer to retry', async t => { + const session = new Session({ sock: new net.Socket() }); + + closeAfter(t, session); + + const warnings: string[] = []; +- const incoming = incomingOn(session, { log: { ...silentLog, warn: message => { warnings.push(message); } } }); + const answers: (ErrorName | undefined)[] = []; + const received: Sms[] = []; ++ const releases: (() => void)[] = []; ++ const incoming = incomingOn(session, { ++ answer: (_pdu, status) => { answers.push(status); return {}; }, ++ log: { ...silentLog, warn: message => { warnings.push(message); } }, ++ onSms: sms => new Promise(resolve => { received.push(sms); releases.push(resolve); }), ++ }); + +- session.sendReturn = (_pdu, status) => { +- answers.push(status); +- +- return Promise.resolve({}); +- }; +- session.on('sms', sms => { received.push(sms); }); +- +- for (let seqNr = 1; seqNr <= defaults.maxHeldMessages; seqNr++) { ++ for (let seqNr = 1; seqNr <= defaults.maxHandledMessages; seqNr++) { + await incoming.handle(submitPdu(seqNr)); + } + +- assert.equal(received.length, defaults.maxHeldMessages); ++ assert.equal(received.length, defaults.maxHandledMessages); + +- await incoming.handle(submitPdu(defaults.maxHeldMessages + 1)); ++ await incoming.handle(submitPdu(defaults.maxHandledMessages + 1)); + await incoming.handle(segment(7, 1, 2)); + +- assert.equal(received.length, defaults.maxHeldMessages); ++ assert.equal(received.length, defaults.maxHandledMessages); + assert.deepEqual(answers, ['ESME_RTHROTTLED', 'ESME_RTHROTTLED']); + assert.equal(warnings.length, 1, 'reaching the bound warns once, not per refusal'); + + // The refused first segment joined no group, so the second one is taken and completes nothing. +- await received[0]?.sendResp(); +- await new Promise(resolve => { setImmediate(resolve); }); ++ releases[0]?.(); ++ await settled(); + await incoming.handle(segment(7, 2, 2)); + + assert.equal(answers.at(-1), 'ESME_ROK'); +- assert.equal(received.length, defaults.maxHeldMessages); ++ assert.equal(received.length, defaults.maxHandledMessages); + + // A peer keeping its window full crosses the bound on every answer, and that is still one warning. +- await received[1]?.sendResp(); +- await new Promise(resolve => { setImmediate(resolve); }); +- await incoming.handle(submitPdu(defaults.maxHeldMessages + 2)); +- await incoming.handle(submitPdu(defaults.maxHeldMessages + 3)); +- await incoming.handle(submitPdu(defaults.maxHeldMessages + 4)); ++ releases[1]?.(); ++ await settled(); ++ await incoming.handle(submitPdu(defaults.maxHandledMessages + 2)); ++ await incoming.handle(submitPdu(defaults.maxHandledMessages + 3)); ++ await incoming.handle(submitPdu(defaults.maxHandledMessages + 4)); + + assert.equal(answers.at(-1), 'ESME_RTHROTTLED'); + assert.equal(warnings.length, 1); +@@ -1666,12 +1682,11 @@ describe('held message bounds', () => { + + closeAfter(t, session); + +- const incoming = incomingOn(session); ++ let received: Sms | undefined; ++ const incoming = incomingOn(session, { onSms: sms => { received = sms; } }); + const chunk = Buffer.alloc(64 * 1024); + const carried = submitPdu(1); +- let received: Sms | undefined; + +- session.on('sms', sms => { received = sms; }); + await incoming.handle({ ...carried, params: { ...carried.params, short_message: chunk.subarray(16, 20) } }); + + const retained = received?.pduObjs[0]?.params.short_message; +@@ -1681,35 +1696,78 @@ describe('held message bounds', () => { + incoming.clear(); + }); + +- test('gives up on a message the application never answers', t => { ++ test('gives up on a handler that never returns', t => { + let now = 0; +- const held = heldOn(t, { max: 10, maxOctets: 1_000_000, now: () => now, timeout: 60 }); ++ const { handled, offer } = handledOn(t, { max: 10, maxOctets: 1_000_000, now: () => now, timeout: 60 }); + +- offer(held, 1); ++ offer(1); + now = 61; + + // The next message sweeps the one that expired, so only the new one is still waited for. +- offer(held, 2); ++ offer(2); + +- assert.equal(held.size, 1); ++ assert.equal(handled.size, 1); + +- held.clear(); ++ handled.clear(); + }); + + // Without this the drain sits out its whole budget before returning what a sweep already settled. +- test('wakes a waiting drain when the last message expires', async t => { ++ test('wakes a waiting drain when the last handler expires', async t => { + let now = 0; +- const held = heldOn(t, { max: 10, maxOctets: 1_000_000, now: () => now, timeout: 60 }); ++ const { handled, offer } = handledOn(t, { max: 10, maxOctets: 1_000_000, now: () => now, timeout: 60 }); + +- offer(held, 1); ++ offer(1); + +- const waiting = held.idle(1000, undefined); ++ const waiting = handled.idle(1000, undefined); + + now = 61; +- held.sweep(); ++ handled.sweep(); + + assert.equal(await waiting, 0); + }); ++ ++ test('answers for a handler that failed before answering, and reports it', async t => { ++ const session = new Session({ sock: new net.Socket() }); ++ const answers: ErrorName[] = []; ++ const reported: Error[] = []; ++ const handled = new HandledMessages({ ++ log: silentLog, ++ max: 10, ++ maxOctets: 1_000_000, ++ onSms: sms => (sms.from === 'answers' ? sms.sendResp() : Promise.reject(new Error('gave up'))), ++ report: err => { reported.push(err); }, ++ timeout: 10_000, ++ }); ++ const route: SmsRoute = { ...noRoute, answer: (_pdu, status) => { answers.push(status); return {}; } }; ++ ++ closeAfter(t, session); ++ handled.offer({ pduObjs: [submitPdu(1)], session }, route, 'ESME_RX_T_APPN'); ++ handled.offer({ pduObjs: [{ ...submitPdu(2), params: { ...submitPdu(2).params, source_addr: 'answers' } }], session }, route, 'ESME_RX_T_APPN'); ++ await settled(); ++ ++ assert.deepEqual(answers.sort(), ['ESME_ROK', 'ESME_RX_T_APPN']); ++ assert.deepEqual(reported.map(err => err.message), ['gave up']); ++ assert.equal(handled.size, 0, 'a failed handler holds nothing'); ++ }); ++ ++ test('answers for a message no handler takes', async t => { ++ const session = new Session({ sock: new net.Socket() }); ++ const answers: ErrorName[] = []; ++ const handled = new HandledMessages({ ++ log: silentLog, ++ max: 10, ++ maxOctets: 1_000_000, ++ onSms: undefined, ++ report: () => undefined, ++ timeout: 10_000, ++ }); ++ ++ closeAfter(t, session); ++ handled.offer({ pduObjs: [submitPdu(1)], session }, { ...noRoute, answer: (_pdu, status) => { answers.push(status); return {}; } }, 'ESME_RTHROTTLED'); ++ await settled(); ++ ++ assert.deepEqual(answers, ['ESME_RTHROTTLED']); ++ }); + }); + + describe('sendResp()', () => { +@@ -1720,21 +1778,49 @@ describe('sendResp()', () => { + closeAfter(t, session); + + let answered = 0; +- +- session.sendReturn = () => Promise.resolve({ err: new Error('Socket is closed') }); +- + const sms = createSms({ + pduObjs: [submitPdu(1)], + session, + }, { ++ ...noRoute, ++ answer: () => ({ err: new Error('Socket is closed') }), + answered: () => { answered++; }, +- lostLink: () => false, +- send: () => Promise.resolve({ err: new Error('never sent') }), + }); + + assert.match((await sms.sendResp()).err?.message ?? '', /Socket is closed/); + assert.equal(answered, 0); + }); ++ ++ test('answers once, and keeps the id it was given only once that answer is on the wire', async t => { ++ const session = new Session({ sock: new net.Socket() }); ++ ++ closeAfter(t, session); ++ ++ const answers: string[] = []; ++ let writable = false; ++ const sms = createSms({ pduObjs: [submitPdu(1)], session }, { ++ ...noRoute, ++ answer: (_pdu, _status, params) => { ++ if (!writable) return { err: new Error('Socket is closed') }; ++ ++ answers.push(paramText(params.message_id)); ++ ++ return {}; ++ }, ++ answered: () => undefined, ++ }); ++ const generated = sms.smsId; ++ ++ assert.ok((await sms.sendResp({ smsId: 'never-written' })).err); ++ assert.equal(sms.smsId, generated, 'a failed answer does not rename the message'); ++ ++ writable = true; ++ ++ assert.deepEqual(await sms.sendResp({ smsId: 'written' }), {}); ++ assert.equal(sms.smsId, 'written'); ++ assert.match((await sms.sendResp()).err?.message ?? '', /already answered/); ++ assert.deepEqual(answers, ['written']); ++ }); + }); + + describe('sendDlr()', () => { +@@ -1749,8 +1835,8 @@ describe('sendDlr()', () => { + pduObjs: [submitPdu(1), submitPdu(2), submitPdu(3)], + session, + }, { ++ ...noRoute, + answered: () => undefined, +- lostLink: () => false, + send: () => { + call++; + +@@ -2351,7 +2437,7 @@ describe('a peer that sends the next segment only once the last one is answered' + params: submitSmParams( + { from: '46701113311', message: text, to: '46709771337' }, + segment, +- { encoding: 'ASCII', multipart: true }, ++ { encoding: 'GSM', multipart: true }, + ), + }); + +@@ -2372,20 +2458,20 @@ describe('a peer that sends the next segment only once the last one is answered' + } + + test('gets every segment answered as it arrives, and the application one whole message', async t => { +- const smpp = await startServer(t); + const messages: Sms[] = []; +- const incoming = once(resolve => { +- smpp.on('session', bound => bound.on('sms', sms => { ++ const incoming = inbox(); ++ const smpp = await startServer(t, { ++ onSms: sms => { + messages.push(sms); +- resolve(sms); +- })); ++ incoming.onSms(sms); ++ }, + }); + const { session } = await connect(t, smpp, { responseTimeout: 1000 }); + + assert.ok(session); + + const answers = await submitSerially(session, 0x2A); +- const sms = await incoming; ++ const sms = await incoming.sms; + + assert.equal(sms.message, text); + assert.equal(sms.pduObjs.length, answers.length); +@@ -2401,10 +2487,8 @@ describe('a peer that sends the next segment only once the last one is answered' + + // The documented single-segment contract, which the segment-by-segment answer must not touch. + test('answers a single-segment message only once the application does, with the id it chose', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', bound => bound.on('sms', resolve)); +- }); ++ const incoming = inbox(); ++ const smpp = await startServer(t, { onSms: incoming.onSms }); + const { session } = await connect(t, smpp); + + assert.ok(session); +@@ -2417,7 +2501,7 @@ describe('a peer that sends the next segment only once the last one is answered' + source_addr: '46701113311', + }, + }); +- const sms = await incoming; ++ const sms = await incoming.sms; + + assert.equal(await within(150, submitted), undefined, 'nothing may answer for the application'); + assert.equal(sms.answeredOnArrival, false); +@@ -2433,17 +2517,15 @@ describe('a peer that sends the next segment only once the last one is answered' + }); + + test('refuses an id and a refusing status for segments already on the wire', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', bound => bound.on('sms', resolve)); +- }); ++ const incoming = inbox(); ++ const smpp = await startServer(t, { onSms: incoming.onSms }); + const { session } = await connect(t, smpp, { responseTimeout: 1000 }); + + assert.ok(session); + + await submitSerially(session, 0x2B); + +- const sms = await incoming; ++ const sms = await incoming.sms; + const named = await sms.sendResp({ smsId: '0199e0ea-1f3d-7ab4-8c21-6d4e5f0a9b73' }); + const refused = await sms.sendResp({ status: 'ESME_RMSGQFUL' }); + +@@ -2454,13 +2536,10 @@ describe('a peer that sends the next segment only once the last one is answered' + }); + + test('reports a half-arrived message it has already answered, and holds nothing after', async t => { +- const smpp = await startServer(t, { reassemblyTimeout: 60 }); + const messages: Sms[] = []; ++ const smpp = await startServer(t, { onSms: sms => { messages.push(sms); }, reassemblyTimeout: 60 }); + const lost = once(resolve => { +- smpp.on('session', bound => { +- bound.on('sessionError', resolve); +- bound.on('sms', sms => { messages.push(sms); }); +- }); ++ smpp.on('session', bound => { bound.on('sessionError', resolve); }); + }); + const { session } = await connect(t, smpp, { responseTimeout: 1000 }); + +@@ -2500,7 +2579,7 @@ describe('a peer that sends the next segment only once the last one is answered' + params: submitSmParams( + { from: '46701113311', message: text, to: '46709771337' }, + first, +- { encoding: 'ASCII', multipart: true }, ++ { encoding: 'GSM', multipart: true }, + ), + }); + +@@ -2512,29 +2591,32 @@ describe('a peer that sends the next segment only once the last one is answered' + }); + + test('close() still waits for a concatenated message the application has not answered', async t => { +- const smpp = await startServer(t, { shutdownTimeout: 50 }); +- const incoming = once(resolve => { +- smpp.on('session', bound => bound.on('sms', resolve)); ++ const incoming = inbox(); ++ const smpp = await startServer(t, { ++ onSms: sms => { ++ incoming.onSms(sms); ++ ++ return stuck(sms); ++ }, ++ shutdownTimeout: 50, + }); + const { session } = await connect(t, smpp, { responseTimeout: 1000 }); + + assert.ok(session); + + await submitSerially(session, 0x2D); +- await incoming; ++ await incoming.sms; + + const closed = await peerOf(smpp).close(); + + assert.ok(closed.err instanceof Error); +- assert.match(closed.err.message, /1 message\(s\) unanswered/); ++ assert.match(closed.err.message, /1 message\(s\) still being handled/); + }); + + // pduObjs.length is 1 either way here, so answeredOnArrival is the only thing that can say. + test('marks a one-part concatenated message answered, as its segment count cannot', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', bound => bound.on('sms', resolve)); +- }); ++ const incoming = inbox(); ++ const smpp = await startServer(t, { onSms: incoming.onSms }); + const { session } = await connect(t, smpp, { responseTimeout: 1000 }); + + assert.ok(session); +@@ -2551,7 +2633,7 @@ describe('a peer that sends the next segment only once the last one is answered' + source_addr: '46701113311', + }, + }); +- const sms = await incoming; ++ const sms = await incoming.sms; + + assert.equal(answered.err, undefined); + assert.ok(answered.pduObj); +@@ -2564,10 +2646,8 @@ describe('a peer that sends the next segment only once the last one is answered' + + // esm_class said there was a UDH, and there is no group its concatenation fields can join. + test('answers a segment whose UDH cannot be honoured rather than leaving the peer waiting', async t => { +- const smpp = await startServer(t); + const messages: Sms[] = []; +- +- smpp.on('session', bound => bound.on('sms', sms => { messages.push(sms); })); ++ const smpp = await startServer(t, { onSms: sms => { messages.push(sms); } }); + + const { session } = await connect(t, smpp, { responseTimeout: 1000 }); + +@@ -2594,10 +2674,8 @@ describe('a peer that sends the next segment only once the last one is answered' + + // Its esm_class is 0x00 and correct, so the refusal names the optional parameters instead. + test('refuses a sar_* segment the TLVs number impossibly by naming those TLVs', async t => { +- const smpp = await startServer(t); + const messages: Sms[] = []; +- +- smpp.on('session', bound => bound.on('sms', sms => { messages.push(sms); })); ++ const smpp = await startServer(t, { onSms: sms => { messages.push(sms); } }); + + const { session } = await connect(t, smpp, { responseTimeout: 1000 }); + +@@ -2662,13 +2740,11 @@ describe('AbortSignal on a send', () => { + t: TestContext, + options: Parameters[0] = {}, + ): Promise { +- const smpp = await startServer(t); ++ // The peer takes the message and never answers it, so the one slot stays held. ++ const smpp = await startServer(t, { onSms: () => undefined }); + const onWire = once(resolve => { + smpp.on('session', bound => bound.on('incomingPduObj', resolve)); + }); +- +- smpp.on('session', bound => bound.on('sms', () => undefined)); +- + const { session } = await connect(t, smpp, { maxOutstanding: 1, ...options }); + + assert.ok(session); +@@ -2716,24 +2792,23 @@ describe('AbortSignal on a send', () => { + }); + + test('leaves the freed slot to the next send rather than to the waiter that gave up', async t => { +- const smpp = await startServer(t); +- const holding = once(resolve => { smpp.on('session', bound => bound.on('sms', resolve)); }); ++ const holding = inbox(); + let firstTaken = false; ++ const smpp = await startServer(t, { ++ onSms: async sms => { ++ holding.onSms(sms); + +- smpp.on('session', bound => { +- bound.on('sms', async sms => { + if (firstTaken) await sms.sendResp(); + + firstTaken = true; +- }); ++ }, + }); +- + const { session } = await connect(t, smpp, { maxOutstanding: 1, responseTimeout: 10_000 }); + + assert.ok(session); + void session.sendSms({ from: '46701113311', message: 'holds the only slot', to: '46709771337' }); + +- const held = await holding; ++ const held = await holding.sms; + const controller = new AbortController(); + const abandoned = session.sendSms( + { from: '46701113311', message: 'abandoned in the queue', to: '46709771337' }, +@@ -2762,15 +2837,22 @@ describe('AbortSignal on a send', () => { + }); + + describe('graceful shutdown', () => { ++ /** A message the server's handler holds until `release()`, its submit still unanswered. */ + async function submitInFlight( + t: TestContext, + options: Parameters[0] = {}, + serverOptions: Parameters[0] = {}, + message = 'answer me', + ) { +- const smpp = await startServer(t, serverOptions); +- const incoming = once(resolve => { +- smpp.on('session', bound => bound.on('sms', resolve)); ++ const incoming = inbox(); ++ const held = holding(); ++ const smpp = await startServer(t, { ++ onSms: sms => { ++ incoming.onSms(sms); ++ ++ return held.onSms(sms); ++ }, ++ ...serverOptions, + }); + const { session } = await connect(t, smpp, options); + +@@ -2778,7 +2860,7 @@ describe('graceful shutdown', () => { + + const sent = session.sendSms({ from: '46701113311', message, to: '46709771337' }); + +- return { sent, session, sms: await incoming, smpp }; ++ return { release: held.release, sent, session, sms: await incoming.sms, smpp }; + } + + test('close() waits out a submit already on the wire and refuses new ones', async t => { +@@ -2824,43 +2906,76 @@ describe('graceful shutdown', () => { + assert.deepEqual(await unbound, {}); + }); + +- test('close() waits for a message the application has not answered yet', async t => { +- const { sent, smpp, sms } = await submitInFlight(t); ++ test('close() waits for a handler that has not returned yet', async t => { ++ const { release, sent, smpp, sms } = await submitInFlight(t); + const closing = peerOf(smpp).close(); + + await delay(50); + await sms.sendResp({ smsId: 'answered-during-the-inbound-drain' }); ++ release(); + + assert.deepEqual(await closing, {}); + assert.deepEqual((await sent).smsIds, ['answered-during-the-inbound-drain']); + }); + +- test('gives up on a message the application never answers', async t => { ++ // The handler's return is what the drain waits for, whether or not the message was answered. ++ test('close() keeps waiting for a handler that answered and is still working', async t => { ++ const { release, sent, smpp, sms } = await submitInFlight(t); ++ ++ await sms.sendResp({ smsId: 'answered-before-the-drain' }); ++ ++ const closing = peerOf(smpp).close(); ++ let closed = false; ++ ++ void closing.then(() => { closed = true; }); ++ await delay(50); ++ ++ assert.equal(closed, false, 'the answer went out, and the handler is still working'); ++ release(); ++ ++ assert.deepEqual(await closing, {}); ++ assert.deepEqual((await sent).smsIds, ['answered-before-the-drain']); ++ }); ++ ++ test('close() stops waiting once the handler returns, answered or not', async t => { ++ const { release, sent, smpp } = await submitInFlight(t, { responseTimeout: 200 }, { shutdownTimeout: 30_000 }); ++ ++ release(); ++ ++ const started = Date.now(); ++ ++ assert.deepEqual(await peerOf(smpp).close(), {}); ++ assert.ok(Date.now() - started < 1000); ++ assert.ok((await sent).err instanceof Error, 'a handler that returned without answering left the peer waiting'); ++ }); ++ ++ test('gives up on a handler that never returns', async t => { + const { smpp } = await submitInFlight(t, {}, { shutdownTimeout: 50 }); + const closed = await peerOf(smpp).close(); + + assert.ok(closed.err instanceof Error); +- assert.match(closed.err.message, /1 message\(s\) unanswered/); ++ assert.match(closed.err.message, /1 message\(s\) still being handled/); + }); + +- // Waiting forever is safe for the peer, which every request times out on. The application is not. +- test('falls back to responseTimeout for a held message when the shutdown waits forever', async t => { +- const { smpp } = await submitInFlight(t, {}, { responseTimeout: 200, shutdownTimeout: 0 }); ++ test('waits out a handler for as long as it takes at shutdownTimeout 0', async t => { ++ const { release, sent, smpp, sms } = await submitInFlight(t, {}, { shutdownTimeout: 0 }); + const started = Date.now(); +- const closed = await peerOf(smpp).close(); +- const waited = Date.now() - started; ++ const closing = peerOf(smpp).close(); + +- assert.ok(closed.err instanceof Error); +- assert.match(closed.err.message, /1 message\(s\) unanswered/); +- assert.ok(waited >= 190, `waited ${String(waited)} ms, so the fallback was not what bounded it`); +- assert.ok(waited < 2000); ++ await delay(200); ++ await sms.sendResp({ smsId: 'answered-late' }); ++ release(); ++ ++ assert.deepEqual(await closing, {}); ++ assert.ok(Date.now() - started >= 190); ++ assert.deepEqual((await sent).smsIds, ['answered-late']); + }); + + // leftOf() floors what is left at 1 ms: at 0 the request half would read "wait forever" instead. +- test('still ends when the message half has spent the whole shutdown budget', async t => { +- const { smpp } = await submitInFlight(t, {}, { shutdownTimeout: 100 }); ++ test('still ends when the handler half has spent the whole shutdown budget', async t => { ++ // The client's handler never returns, so its answer never comes and the request stays in the window. ++ const { smpp } = await submitInFlight(t, { onSms: stuck }, { shutdownTimeout: 100 }); + const bound = peerOf(smpp); +- // The client listens for no 'sms', so this one is never answered and stays in the window. + const unanswered = bound.send({ + cmdName: 'submit_sm', + params: { +@@ -2876,14 +2991,13 @@ describe('graceful shutdown', () => { + }), + ]); + +- assert.match(closed.err?.message ?? '', /1 message\(s\) unanswered; .*1 request\(s\) unfinished/); ++ assert.match(closed.err?.message ?? '', /1 message\(s\) still being handled; .*1 request\(s\) unfinished/); + assert.ok((await unanswered).err instanceof Error); + }); + +- // The README's own listener answers and then sends its receipt, one turn later. Multipart, because +- // a receipt sent one-after-a-response outruns that turn on every segment past the first. +- test('a receipt sent right after the response still goes out mid-drain', async t => { +- const { sent, session, smpp, sms } = await submitInFlight(t, {}, {}, 'x'.repeat(400)); ++ // The README's own handler answers and then sends its receipt; both go out inside the handler. ++ test('a receipt sent after the response still goes out mid-drain', async t => { ++ const { release, sent, session, smpp, sms } = await submitInFlight(t, {}, {}, 'x'.repeat(400)); + const received: Dlr[] = []; + const receipts = once(resolve => { + session.on('dlr', dlr => { +@@ -2901,11 +3015,12 @@ describe('graceful shutdown', () => { + + assert.equal(receiptSent.err, undefined); + assert.deepEqual((await receipts).map(dlr => dlr.smsId), ids); ++ release(); + assert.deepEqual(await closing, {}); + assert.deepEqual((await sent).smsIds, ids); + }); + +- test('a message no listener took does not hold the shutdown up', async t => { ++ test('a message no handler takes is refused at once and does not hold the shutdown up', async t => { + const smpp = await startServer(t, { shutdownTimeout: 30_000 }); + const { session } = await connect(t, smpp); + +@@ -2924,23 +3039,22 @@ describe('graceful shutdown', () => { + }); + + await arrived; +- await delay(50); + ++ const refused = await sent; + const started = Date.now(); + ++ assert.match(refused.err?.message ?? '', /ESME_RTHROTTLED/, 'the peer is told to retry, not left waiting'); + assert.deepEqual(await bound.close(), {}); + assert.ok(Date.now() - started < 1000); +- assert.ok((await sent).err instanceof Error); + }); + +- // emit() releases the hold of a listener that throws; one that rejects may cost no more than that. +- test('a listener that rejected before answering does not hold the shutdown up', async t => { +- const smpp = await startServer(t, { shutdownTimeout: 30_000 }); ++ test('a handler that rejected before answering has the message refused for it, and holds nothing', async t => { ++ const smpp = await startServer(t, { ++ onSms: () => Promise.reject(new Error('the handler gave up')), ++ shutdownTimeout: 30_000, ++ }); + const failed = once(resolve => { +- smpp.on('session', bound => { +- bound.on('sessionError', resolve); +- bound.on('sms', () => Promise.reject(new Error('the listener gave up'))); +- }); ++ smpp.on('session', bound => { bound.on('sessionError', resolve); }); + }); + const { session } = await connect(t, smpp); + +@@ -2948,55 +3062,50 @@ describe('graceful shutdown', () => { + + const sent = session.sendSms({ + from: '46701113311', +- message: 'the listener rejects', ++ message: 'the handler rejects', + to: '46709771337', + }); + +- assert.equal((await failed).message, 'the listener gave up'); ++ assert.equal((await failed).message, 'the handler gave up'); + ++ const refused = await sent; + const started = Date.now(); + ++ assert.match(refused.err?.message ?? '', /ESME_RTHROTTLED/); + assert.deepEqual(await peerOf(smpp).close(), {}); + assert.ok(Date.now() - started < 1000); +- assert.ok((await sent).err instanceof Error); + }); + +- test('waits for the listener still working when another one rejected', async t => { +- const smpp = await startServer(t, { shutdownTimeout: 30_000 }); ++ test('a handler that rejected after answering leaves that answer alone', async t => { ++ const smpp = await startServer(t, { ++ onSms: async sms => { ++ await sms.sendResp({ smsId: 'answered-then-failed' }); ++ ++ throw new Error('failed after answering'); ++ }, ++ }); + const failed = once(resolve => { +- smpp.on('session', bound => { +- bound.on('sessionError', resolve); +- bound.on('sms', async sms => { +- await delay(100); +- await sms.sendResp({ smsId: 'answered-after-the-other-gave-up' }); +- }); +- bound.on('sms', () => Promise.reject(new Error('the audit listener gave up'))); +- }); ++ smpp.on('session', bound => { bound.on('sessionError', resolve); }); + }); + const { session } = await connect(t, smpp); + + assert.ok(session); + +- const sent = session.sendSms({ +- from: '46701113311', +- message: 'two listeners, one gives up', +- to: '46709771337', +- }); ++ const sent = await session.sendSms({ from: '46701113311', message: 'answered first', to: '46709771337' }); + +- assert.equal((await failed).message, 'the audit listener gave up'); +- assert.deepEqual(await peerOf(smpp).close(), {}); +- assert.deepEqual((await sent).smsIds, ['answered-after-the-other-gave-up']); ++ assert.equal((await failed).message, 'failed after answering'); ++ assert.deepEqual(sent.smsIds, ['answered-then-failed']); + }); + +- // Nothing reached the peer, so a drain counting this answered would report an outcome that never was. +- test('leaves a message the library refused to answer unanswered', async t => { ++ // A refused answer changes nothing: the handler is still running, so the drain still waits on it. ++ test('keeps waiting on a handler whose answer the library refused', async t => { + const { sms, smpp } = await submitInFlight(t, {}, { shutdownTimeout: 50 }); + const refused = await sms.sendResp({ smsId: '' }); + const closed = await peerOf(smpp).close(); + + assert.match(refused.err?.message ?? '', /smsId must not be empty/); + assert.ok(closed.err instanceof Error); +- assert.match(closed.err.message, /1 message\(s\) unanswered/); ++ assert.match(closed.err.message, /1 message\(s\) still being handled/); + }); + + test('gives up on a request that outlasts shutdownTimeout', async t => { +@@ -3031,8 +3140,8 @@ describe('graceful shutdown', () => { + + // The queued segments are the whole reason the drain waits on the window and not on the pending map. + test('counts the segments still queued behind a full window', async t => { +- // No 'sms' listener, so the single-segment message holding the only slot is never answered. +- const smpp = await startServer(t); ++ // The handler never returns nor answers, so the single-segment message holds the only slot. ++ const smpp = await startServer(t, { onSms: stuck }); + const onWire = once(resolve => { + smpp.on('session', bound => bound.on('incomingPduObj', resolve)); + }); +@@ -3208,12 +3317,7 @@ describe('message id notation', () => { + } + + test('correlates a hex submit_sm_resp against a decimal receipt', async t => { +- const smpp = await startServer(t); +- +- smpp.on('session', bound => { +- bound.on('sms', sms => { void sms.sendResp({ smsId: '1a2b' }); }); +- }); +- ++ const smpp = await startServer(t, { onSms: sms => sms.sendResp({ smsId: '1a2b' }) }); + const { session } = await connect(t, smpp, { + smsIdFormat: { receipt: 'decimal', submitResp: 'hex' }, + }); +@@ -3238,12 +3342,13 @@ describe('message id notation', () => { + }); + + test('leaves the segment ids of a multipart send to merge as they are', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', bound => bound.on('sms', sms => { +- resolve(sms); +- void sms.sendResp(); +- })); ++ const incoming = inbox(); ++ const smpp = await startServer(t, { ++ onSms: sms => { ++ incoming.onSms(sms); ++ ++ return sms.sendResp(); ++ }, + }); + const { session } = await connect(t, smpp, { + smsIdFormat: { receipt: 'decimal', submitResp: 'hex' }, +@@ -3253,7 +3358,7 @@ describe('message id notation', () => { + + const merged = once(resolve => { session.on('messageDlr', resolve); }); + const sent = await sendOne(session, 'x'.repeat(200)); +- const sms = await incoming; ++ const sms = await incoming.sms; + + assert.deepEqual(sent.smsIds, [1, 2].map(part => `${sms.smsId}-${String(part)}`)); + +diff --git a/test/session.test.ts b/test/session.test.ts +index 1f31f0b..32e1e0b 100644 +--- a/test/session.test.ts ++++ b/test/session.test.ts +@@ -74,6 +74,16 @@ function raceWithin(ms: number, promise: Promise): Promise { + return Promise.race([promise, delay(ms).then((): false => false)]); + } + ++type Inbox = { onSms: (sms: Sms) => void; sms: Promise }; ++ ++/** Hands the test the first message and returns at once, so nothing is held. */ ++function inbox(): Inbox { ++ let deliver: (sms: Sms) => void = () => undefined; ++ const sms = once(resolve => { deliver = resolve; }); ++ ++ return { onSms: message => { deliver(message); }, sms }; ++} ++ + /** A socket read as a queue of parsed PDUs; `handled` takes the ones the peer answers itself. */ + function pduQueue( + sock: net.Socket, +@@ -507,10 +517,8 @@ describe('bind direction', () => { + }); + + test('refuses sendSms() on a receiver-bound session before it reaches the wire', async t => { +- const smpp = await startServer(t); + const arrived: Sms[] = []; +- +- smpp.on('session', peer => peer.on('sms', sms => arrived.push(sms))); ++ const smpp = await startServer(t, { onSms: sms => { arrived.push(sms); } }); + + const { session } = await connect(t, smpp, { bindType: 'receiver' }); + +@@ -542,16 +550,14 @@ describe('bind direction', () => { + }); + + test('refuses sendDlr() to a transmitter-bound peer before it reaches the wire', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', peer => peer.on('sms', resolve)); +- }); ++ const incoming = inbox(); ++ const smpp = await startServer(t, { onSms: incoming.onSms }); + const { session } = await connect(t, smpp, { bindType: 'transmitter' }); + + assert.ok(session); + + const [sms] = await Promise.all([ +- incoming.then(async received => { ++ incoming.sms.then(async received => { + await received.sendResp(); + + return received; +@@ -585,9 +591,7 @@ describe('bind direction', () => { + }); + + test('refuses a data_sm from a receiver-bound peer, and carries one from a transmitter', async t => { +- const smpp = await startServer(t); +- +- smpp.on('session', peer => peer.on('sms', sms => void sms.sendResp())); ++ const smpp = await startServer(t, { onSms: sms => sms.sendResp() }); + + const receiving = await connect(t, smpp, { bindType: 'receiver' }); + +@@ -619,16 +623,14 @@ describe('bind direction', () => { + + describe('sending', () => { + test('delivers a simple SMS with the sender TON derived from the address', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); ++ const incoming = inbox(); ++ const smpp = await startServer(t, { onSms: incoming.onSms }); + const { session } = await connect(t, smpp); + + assert.ok(session); + + const [sms, sent] = await Promise.all([ +- incoming.then(async received => { ++ incoming.sms.then(async received => { + const refused = await received.sendResp({ smsId: '' }); + + assert.ok(refused.err instanceof Error); +@@ -656,17 +658,15 @@ describe('sending', () => { + }); + + test('reassembles a long SMS and answers every segment', async t => { +- const smpp = await startServer(t); ++ const incoming = inbox(); ++ const smpp = await startServer(t, { onSms: incoming.onSms }); + const message = 'Lorem ipsum dolor sit amet, '.repeat(20); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); + const { session } = await connect(t, smpp); + + assert.ok(session); + + const [sms, sent] = await Promise.all([ +- incoming.then(async received => { ++ incoming.sms.then(async received => { + await received.sendResp(); + + return received; +@@ -682,17 +682,15 @@ describe('sending', () => { + }); + + test('carries a UCS2 message through unchanged', async t => { +- const smpp = await startServer(t); ++ const incoming = inbox(); ++ const smpp = await startServer(t, { onSms: incoming.onSms }); + const message = 'räksmörgås تست 一'; +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); + const { session } = await connect(t, smpp); + + assert.ok(session); + + const [sms] = await Promise.all([ +- incoming.then(async received => { ++ incoming.sms.then(async received => { + await received.sendResp(); + + return received; +@@ -705,16 +703,14 @@ describe('sending', () => { + }); + + test('marks a flash message without losing the UCS2 alphabet', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); ++ const incoming = inbox(); ++ const smpp = await startServer(t, { onSms: incoming.onSms }); + const { session } = await connect(t, smpp); + + assert.ok(session); + + const [sms] = await Promise.all([ +- incoming.then(async received => { ++ incoming.sms.then(async received => { + await received.sendResp(); + + return received; +@@ -729,16 +725,14 @@ describe('sending', () => { + }); + + test('puts the address TON and NPI the caller chose on the wire', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', peer => peer.on('sms', resolve)); +- }); ++ const incoming = inbox(); ++ const smpp = await startServer(t, { onSms: incoming.onSms }); + const { session } = await connect(t, smpp); + + assert.ok(session); + + const [sms] = await Promise.all([ +- incoming.then(async received => { ++ incoming.sms.then(async received => { + await received.sendResp(); + + return received; +@@ -768,11 +762,12 @@ describe('receiving', () => { + async function inbound( + t: TestContext, + options: ServerOptions = {}, ++ clientOptions: Parameters[0] = {}, + ): Promise<{ peer: Session; session: Session }> { + const smpp = await startServer(t, options); + + const bound = once(resolve => { smpp.on('session', resolve); }); +- const { session } = await connect(t, smpp); ++ const { session } = await connect(t, smpp, clientOptions); + + assert.ok(session); + +@@ -780,8 +775,8 @@ describe('receiving', () => { + } + + test('hands a client a deliver_sm that is not a delivery receipt', async t => { +- const { peer, session } = await inbound(t); +- const incoming = once(resolve => { session.on('sms', resolve); }); ++ const incoming = inbox(); ++ const { peer } = await inbound(t, {}, { onSms: incoming.onSms }); + const delivered = peer.send({ + cmdName: 'deliver_sm', + params: { +@@ -790,7 +785,7 @@ describe('receiving', () => { + source_addr: '46701113311', + }, + }); +- const sms = await raceWithin(2000, incoming); ++ const sms = await raceWithin(2000, incoming.sms); + + assert.ok(sms, 'a deliver_sm that carries no receipt is an inbound SMS'); + assert.equal(sms.from, '46701113311'); +@@ -813,8 +808,8 @@ describe('receiving', () => { + + // SMPP 3.4 5.3.2.32: up to 64 KB of body in a TLV, with sm_length 0 and short_message empty. + test('reads an inbound message the peer carried in message_payload', async t => { +- const { peer, session } = await inbound(t); +- const incoming = once(resolve => { session.on('sms', resolve); }); ++ const incoming = inbox(); ++ const { peer } = await inbound(t, {}, { onSms: incoming.onSms }); + const text = 'the whole body, carried in the TLV'; + const delivered = peer.send({ + cmdName: 'deliver_sm', +@@ -825,7 +820,7 @@ describe('receiving', () => { + }, + tlvs: { message_payload: { tagValue: Buffer.from(text, 'latin1') } }, + }); +- const sms = await raceWithin(2000, incoming); ++ const sms = await raceWithin(2000, incoming.sms); + + assert.ok(sms, 'a body the peer put in message_payload is still a message'); + assert.equal(sms.message, text); +@@ -841,15 +836,15 @@ describe('receiving', () => { + }); + + test('hands a client a data_sm carrying a message as an sms, answered data_sm_resp', async t => { +- const { peer, session } = await inbound(t); +- const incoming = once(resolve => { session.on('sms', resolve); }); ++ const incoming = inbox(); ++ const { peer } = await inbound(t, {}, { onSms: incoming.onSms }); + const smsId = '0199e0f1-6c31-7a44-9d02-4b7e51c3a806'; + const delivered = peer.send({ + cmdName: 'data_sm', + params: { destination_addr: '46709771337', source_addr: '46701113311' }, + tlvs: { message_payload: { tagValue: Buffer.from('a message carried on the data command', 'latin1') } }, + }); +- const sms = await raceWithin(2000, incoming); ++ const sms = await raceWithin(2000, incoming.sms); + + assert.ok(sms, 'data_sm is a peer of deliver_sm, not a command to refuse'); + assert.equal(sms.message, 'a message carried on the data command'); +@@ -867,12 +862,10 @@ describe('receiving', () => { + }); + + test('hands a client a receipt carried on data_sm as a dlr', async t => { +- const { peer, session } = await inbound(t); ++ let messages = 0; ++ const { peer, session } = await inbound(t, {}, { onSms: () => { messages++; } }); + const reported = once(resolve => { session.on('dlr', resolve); }); + const smsId = '0199e0f1-b8a2-7f19-8c63-2d5041fb9e77'; +- let messages = 0; +- +- session.on('sms', () => { messages++; }); + + const delivered = peer.send({ + cmdName: 'data_sm', +@@ -903,19 +896,19 @@ describe('receiving', () => { + + // At the SMSC end an inbound data_sm is a submission, so nothing in one reports on our own sends. + test('reads a receipt-shaped data_sm submitted to a server as the message it is', async t => { +- const smpp = await startServer(t); + const body = 'id:0199e0f2-2d15-7b83-a4c1-6e90b7d2f345 stat:DELIVRD err:000 text:'; + const messages: Sms[] = []; + const reports: Dlr[] = []; +- +- smpp.on('session', peer => { +- peer.on('dlr', dlr => { reports.push(dlr); }); +- peer.on('sms', sms => { ++ const smpp = await startServer(t, { ++ onSms: sms => { + messages.push(sms); +- void sms.sendResp(); +- }); ++ ++ return sms.sendResp(); ++ }, + }); + ++ smpp.on('session', peer => { peer.on('dlr', dlr => { reports.push(dlr); }); }); ++ + const { session } = await connect(t, smpp, { bindType: 'transmitter' }); + + assert.ok(session); +@@ -965,8 +958,8 @@ describe('receiving', () => { + }); + + test('reassembles a concatenated message whose segments arrived in message_payload', async t => { +- const { peer, session } = await inbound(t); +- const incoming = once(resolve => { session.on('sms', resolve); }); ++ const incoming = inbox(); ++ const { peer } = await inbound(t, {}, { onSms: incoming.onSms }); + const text = 'A body in the TLV is still numbered by its UDH. '.repeat(6); + const segments = splitMessage(text, { reference: 0x3B }); + +@@ -990,7 +983,7 @@ describe('receiving', () => { + answers.push(sent.pduObj); + } + +- const sms = await raceWithin(2000, incoming); ++ const sms = await raceWithin(2000, incoming.sms); + + assert.ok(sms, 'the segments join into one message wherever their bodies were carried'); + assert.equal(sms.message, text); +@@ -1005,11 +998,9 @@ describe('receiving', () => { + }); + + test('hands a client a report as a dlr rather than as an sms', async t => { +- const { peer, session } = await inbound(t); +- const reported = once(resolve => { session.on('dlr', resolve); }); + let messages = 0; +- +- session.on('sms', () => { messages++; }); ++ const { peer, session } = await inbound(t, {}, { onSms: () => { messages++; } }); ++ const reported = once(resolve => { session.on('dlr', resolve); }); + + const delivered = peer.send({ + cmdName: 'deliver_sm', +@@ -1085,8 +1076,8 @@ describe('receiving', () => { + + test('reassembles a multipart inbound SMS before the sms event', async t => { + const message = 'Inbound lorem ipsum dolor sit amet consectetur, '.repeat(6); +- const { peer, session } = await inbound(t); +- const incoming = once(resolve => { session.on('sms', resolve); }); ++ const incoming = inbox(); ++ const { peer } = await inbound(t, {}, { onSms: incoming.onSms }); + const segments = splitMessage(message, { reference: 42 }); + + assert.equal(segments.length, 2); +@@ -1100,7 +1091,7 @@ describe('receiving', () => { + source_addr: '46701113311', + }, + }))); +- const sms = await raceWithin(2000, incoming); ++ const sms = await raceWithin(2000, incoming.sms); + + assert.ok(sms, 'both segments should reassemble into one message'); + assert.equal(sms.message, message); +@@ -1124,8 +1115,8 @@ describe('receiving', () => { + } + + test('answers every sar_* segment on arrival and hands the application one message', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { smpp.on('session', peer => peer.on('sms', resolve)); }); ++ const incoming = inbox(); ++ const smpp = await startServer(t, { onSms: incoming.onSms }); + const { session } = await connect(t, smpp, { bindType: 'transmitter', responseTimeout: 1000 }); + + assert.ok(session); +@@ -1149,7 +1140,7 @@ describe('receiving', () => { + answers.push(sent.pduObj); + } + +- const sms = await raceWithin(2000, incoming); ++ const sms = await raceWithin(2000, incoming.sms); + + assert.ok(sms, 'the sar_* TLVs tie the three submissions into one message'); + assert.equal(sms.message, parts.join('')); +@@ -1161,8 +1152,8 @@ describe('receiving', () => { + }); + + test('joins sar_* segments in the order they number themselves', async t => { +- const { peer, session } = await inbound(t, { responseTimeout: 1000 }); +- const incoming = once(resolve => { session.on('sms', resolve); }); ++ const incoming = inbox(); ++ const { peer } = await inbound(t, { responseTimeout: 1000 }, { onSms: incoming.onSms }); + const parts = ['first ', 'second ', 'third']; + + for (const index of [2, 0, 1]) { +@@ -1180,15 +1171,15 @@ describe('receiving', () => { + assert.equal(sent.pduObj.cmdStatus, 'ESME_ROK'); + } + +- const sms = await raceWithin(2000, incoming); ++ const sms = await raceWithin(2000, incoming.sms); + + assert.ok(sms, 'the segments number themselves, so the order they arrive in is not the message'); + assert.equal(sms.message, parts.join('')); + }); + + test('reassembles a sar_* segment whose body is in message_payload', async t => { +- const { peer, session } = await inbound(t, { responseTimeout: 1000 }); +- const incoming = once(resolve => { session.on('sms', resolve); }); ++ const incoming = inbox(); ++ const { peer } = await inbound(t, { responseTimeout: 1000 }, { onSms: incoming.onSms }); + const parts = ['in the mandatory field, ', 'and in the TLV']; + + for (const [index, part] of parts.entries()) { +@@ -1214,7 +1205,7 @@ describe('receiving', () => { + assert.equal(sent.pduObj.cmdStatus, 'ESME_ROK'); + } + +- const sms = await raceWithin(2000, incoming); ++ const sms = await raceWithin(2000, incoming.sms); + + assert.ok(sms, 'a segment carries its body where any other message may carry one'); + assert.equal(sms.message, parts.join('')); +@@ -1223,10 +1214,8 @@ describe('receiving', () => { + + // The UDH reference is 8 bits and sar_msg_ref_num is 16, so the same number is two messages. + test('keeps a UDH group and a sar_* group sharing a reference apart', async t => { +- const { peer, session } = await inbound(t, { responseTimeout: 1000 }); + const messages: Sms[] = []; +- +- session.on('sms', sms => { messages.push(sms); }); ++ const { peer } = await inbound(t, { responseTimeout: 1000 }, { onSms: sms => { messages.push(sms); } }); + + const udhText = 'the message numbered by its user data header. '.repeat(5); + const udhSegments = splitMessage(udhText, { reference: 5 }); +@@ -1271,8 +1260,8 @@ describe('receiving', () => { + + // Nothing compares the two references: each spelling counts in a space of its own. + test('groups a segment carrying both spellings by its UDH', async t => { +- const { peer, session } = await inbound(t, { responseTimeout: 1000 }); +- const incoming = once(resolve => { session.on('sms', resolve); }); ++ const incoming = inbox(); ++ const { peer } = await inbound(t, { responseTimeout: 1000 }, { onSms: incoming.onSms }); + const message = 'both spellings on every segment of it, and the UDH decides. '.repeat(4); + const segments = splitMessage(message, { reference: 7 }); + +@@ -1294,19 +1283,18 @@ describe('receiving', () => { + assert.ok(sent.pduObj); + } + +- const sms = await raceWithin(2000, incoming); ++ const sms = await raceWithin(2000, incoming.sms); + + assert.ok(sms, 'the UDH says the message is whole at two segments, and the UDH is what is read'); + assert.equal(sms.message, message); + }); + + test('reads a receipt carrying sar_* fields as a dlr, never as a segment', async t => { +- const { peer, session } = await inbound(t, { responseTimeout: 1000 }); + const reports: Dlr[] = []; + let messages = 0; ++ const { peer, session } = await inbound(t, { responseTimeout: 1000 }, { onSms: () => { messages++; } }); + + session.on('dlr', dlr => { reports.push(dlr); }); +- session.on('sms', () => { messages++; }); + + const marked = '0199e1a4-6c3f-7d21-9a80-5b1e2f7c4d63'; + const unmarked = '0199e1a4-b70e-7c55-8f42-9d3a1c86e70b'; +@@ -1350,10 +1338,8 @@ describe('receiving', () => { + + describe('delivery reports', () => { + test('reaches the sender as a dlr event', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); ++ const incoming = inbox(); ++ const smpp = await startServer(t, { onSms: incoming.onSms }); + const { session } = await connect(t, smpp); + + assert.ok(session); +@@ -1363,7 +1349,7 @@ describe('delivery reports', () => { + }); + + const [sms] = await Promise.all([ +- incoming.then(async received => { ++ incoming.sms.then(async received => { + await received.sendResp({ smsId: 'dlr-id' }); + + return received; +@@ -1383,10 +1369,8 @@ describe('delivery reports', () => { + }); + + test('reports a failure with the spec status code', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); ++ const incoming = inbox(); ++ const smpp = await startServer(t, { onSms: incoming.onSms }); + const { session } = await connect(t, smpp); + + assert.ok(session); +@@ -1401,7 +1385,7 @@ describe('delivery reports', () => { + }); + + const [sms] = await Promise.all([ +- incoming.then(async received => { ++ incoming.sms.then(async received => { + await received.sendResp({ smsId: 'fail-id' }); + + return received; +@@ -1417,10 +1401,8 @@ describe('delivery reports', () => { + }); + + test('sends a text-only receipt to a peer that declared less than 3.4', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); ++ const incoming = inbox(); ++ const smpp = await startServer(t, { onSms: incoming.onSms }); + const peer = rawPeer(t, smpp.port); + + peer.write(bindOf(0x33)); +@@ -1438,7 +1420,7 @@ describe('delivery reports', () => { + seqNr: 2, + }); + +- const sms = await incoming; ++ const sms = await incoming.sms; + + await sms.sendResp(); + await peer.next(); +@@ -1454,10 +1436,8 @@ describe('delivery reports', () => { + }); + + test('merges nothing for a message that asked for no receipt', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); ++ const incoming = inbox(); ++ const smpp = await startServer(t, { onSms: incoming.onSms }); + const { session } = await connect(t, smpp); + + assert.ok(session); +@@ -1469,7 +1449,7 @@ describe('delivery reports', () => { + session.on('messageDlr', () => { merged++; }); + + const [sms] = await Promise.all([ +- incoming.then(async received => { ++ incoming.sms.then(async received => { + await received.sendResp(); + + return received; +@@ -1497,10 +1477,8 @@ describe('a session captured from Kannel', () => { + const expected = 'Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry\'s standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum'; + + async function replay(t: TestContext, order: number[]): Promise { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); ++ const incoming = inbox(); ++ const smpp = await startServer(t, { onSms: incoming.onSms }); + + const sock = net.connect({ port: smpp.port }, () => { + sock.write(Buffer.from('0000002100000009000000000000002f666f6f0062617200736d70700034000000', 'hex')); +@@ -1522,7 +1500,7 @@ describe('a session captured from Kannel', () => { + } + }); + +- const sms = await incoming; ++ const sms = await incoming.sms; + + await sms.sendResp(); + +@@ -1598,21 +1576,18 @@ describe('robustness', () => { + }); + + test('keeps at most maxOutstanding requests on the wire', async t => { +- const smpp = await startServer(t); + let concurrent = 0; + let peak = 0; +- +- smpp.on('session', session => { +- session.on('sms', sms => { ++ const smpp = await startServer(t, { ++ onSms: sms => { + concurrent++; + peak = Math.max(peak, concurrent); + setTimeout(() => { + concurrent--; + void sms.sendResp(); + }, 10); +- }); ++ }, + }); +- + const { session } = await connect(t, smpp, { maxOutstanding: 2 }); + + assert.ok(session); +@@ -1653,12 +1628,7 @@ describe('robustness', () => { + }); + + test('ignores events from the socket it left behind on a reconnect', async t => { +- const smpp = await startServer(t); +- +- smpp.on('session', bound => { +- bound.on('sms', sms => { void sms.sendResp(); }); +- }); +- ++ const smpp = await startServer(t, { onSms: sms => sms.sendResp() }); + const { session } = await connect(t, smpp, { reconnect: { maxDelay: 50, minDelay: 10 } }); + + assert.ok(session); +@@ -1731,11 +1701,8 @@ describe('robustness', () => { + + // The guard sits before the send window, or a full window makes the aborted call queue first. + test('does not wait for a send window slot it will never use', async t => { +- const smpp = await startServer(t); +- + // The peer answers nothing, so the one slot stays held for the whole test. +- smpp.on('session', session => session.on('sms', () => undefined)); +- ++ const smpp = await startServer(t, { onSms: () => undefined }); + const { session } = await connect(t, smpp, { maxOutstanding: 1, responseTimeout: 5000 }); + + assert.ok(session); +@@ -1972,13 +1939,10 @@ describe('application hooks that throw or reject', () => { + assert.equal(reported.message, 'authenticate exploded'); + }); + +- test('turns a throwing sms listener into a session error', async t => { +- const smpp = await startServer(t); ++ test('turns a throwing sms handler into a session error', async t => { ++ const smpp = await startServer(t, { onSms: () => { throw new Error('handler exploded'); } }); + const failed = once(resolve => { +- smpp.on('session', session => { +- session.on('sessionError', resolve); +- session.on('sms', () => { throw new Error('listener exploded'); }); +- }); ++ smpp.on('session', session => { session.on('sessionError', resolve); }); + }); + const { session } = await connect(t, smpp, { responseTimeout: 200 }); + +@@ -1992,17 +1956,16 @@ describe('application hooks that throw or reject', () => { + const reported = await raceWithin(500, failed); + + assert.ok(sent.err instanceof Error); +- assert.ok(reported instanceof Error, 'a throwing sms listener should reach the session'); +- assert.equal(reported.message, 'listener exploded'); ++ assert.ok(reported instanceof Error, 'a throwing sms handler should reach the session'); ++ assert.equal(reported.message, 'handler exploded'); + }); + +- // The guard for a throwing sms listener used to emit sessionError from inside its own catch. ++ // The guard for a throwing sms handler used to emit sessionError from inside its own catch. + test('survives a sessionError listener that throws as well', async t => { +- const smpp = await startServer(t); ++ const smpp = await startServer(t, { onSms: () => { throw new Error('handler exploded'); } }); + + smpp.on('session', session => { + session.on('sessionError', () => { throw new Error('the reporter exploded too'); }); +- session.on('sms', () => { throw new Error('listener exploded'); }); + }); + + const { session } = await connect(t, smpp, { responseTimeout: 200 }); +@@ -2018,18 +1981,17 @@ describe('application hooks that throw or reject', () => { + assert.ok(sent.err instanceof Error); + }); + +- test('normalises whatever a rejecting async sms listener threw into a session error', async t => { +- const smpp = await startServer(t); ++ test('normalises whatever a rejecting async sms handler threw into a session error', async t => { + const reason: unknown = null; +- const failed = once(resolve => { +- smpp.on('session', session => { +- session.on('sessionError', resolve); +- session.on('sms', async sms => { +- await sms.sendResp(); ++ const smpp = await startServer(t, { ++ onSms: async sms => { ++ await sms.sendResp(); + +- throw reason; +- }); +- }); ++ throw reason; ++ }, ++ }); ++ const failed = once(resolve => { ++ smpp.on('session', session => { session.on('sessionError', resolve); }); + }); + const { session } = await connect(t, smpp, { responseTimeout: 200 }); + +@@ -2043,16 +2005,15 @@ describe('application hooks that throw or reject', () => { + const reported = await raceWithin(500, failed); + + assert.equal(sent.err, undefined); +- assert.ok(reported instanceof Error, 'a rejecting sms listener should reach the session'); ++ assert.ok(reported instanceof Error, 'a rejecting sms handler should reach the session'); + assert.equal(reported.message, 'null'); + }); + + test('survives a sessionError listener that rejects as well', async t => { +- const smpp = await startServer(t); ++ const smpp = await startServer(t, { onSms: () => Promise.reject(new Error('handler rejected')) }); + + smpp.on('session', session => { + session.on('sessionError', () => Promise.reject(new Error('the reporter rejected too'))); +- session.on('sms', () => Promise.reject(new Error('listener rejected'))); + }); + + const { session } = await connect(t, smpp, { responseTimeout: 200 }); +@@ -2099,12 +2060,7 @@ describe('application hooks that throw or reject', () => { + test('sends on through an application logger that throws', async t => { + const thrower = (): void => { throw new Error('the logger exploded'); }; + const log: SmppLog = { debug: thrower, error: thrower, info: thrower, verbose: thrower, warn: thrower }; +- const smpp = await startServer(t, { log }); +- +- smpp.on('session', session => { +- session.on('sms', sms => { void sms.sendResp(); }); +- }); +- ++ const smpp = await startServer(t, { log, onSms: sms => sms.sendResp() }); + const { session } = await connect(t, smpp, { log }); + + assert.ok(session); +@@ -2130,12 +2086,7 @@ describe('application hooks that throw or reject', () => { + }); + + test('keeps the message id off a submit_sm_resp that refuses the message', async t => { +- const smpp = await startServer(t); +- +- smpp.on('session', bound => { +- bound.on('sms', sms => { void sms.sendResp({ status: 'ESME_RMSGQFUL' }); }); +- }); +- ++ const smpp = await startServer(t, { onSms: sms => sms.sendResp({ status: 'ESME_RMSGQFUL' }) }); + const peer = rawPeer(t, smpp.port); + + peer.write(bindOf(0x34)); +@@ -2269,6 +2220,7 @@ describe('the server\'s onRequest hook', () => { + } + + test('refuses an inbound submit_sm with the status the hook chose', async t => { ++ let messages = 0; + const smpp = await startServer(t, { + onRequest: async (bound, pduObj) => { + if (!isCommand(pduObj, 'submit_sm')) return false; +@@ -2277,10 +2229,8 @@ describe('the server\'s onRequest hook', () => { + + return true; + }, ++ onSms: () => { messages++; }, + }); +- let messages = 0; +- +- smpp.on('session', bound => bound.on('sms', () => { messages++; })); + + const { session } = await connect(t, smpp); + +@@ -2291,7 +2241,7 @@ describe('the server\'s onRequest hook', () => { + assert.equal(answered.err, undefined); + assert.ok(answered.pduObj); + assert.equal(answered.pduObj.cmdStatus, 'ESME_RINVDSTADR'); +- assert.equal(messages, 0, 'a request the hook answered never reaches the sms event'); ++ assert.equal(messages, 0, 'a request the hook answered never reaches the sms handler'); + }); + + test('answers a bind itself, so a hook that claims every request cannot intercept one', async t => { +@@ -2332,23 +2282,22 @@ describe('the server\'s onRequest hook', () => { + assert.deepEqual(seen, [], 'a bind, and everything a peer sends before one, is never the hook\'s'); + }); + +- test('passes a declined request to the sms event, and offers the keepalive and the unbind too', async t => { ++ test('passes a declined request to the sms handler, and offers the keepalive and the unbind too', async t => { + const seen: string[] = []; ++ const incoming = inbox(); + const smpp = await startServer(t, { + onRequest: (_bound, pduObj) => { seen.push(pduObj.cmdName); return false; }, +- }); +- const incoming = once(resolve => { +- smpp.on('session', bound => bound.on('sms', async sms => { +- resolve(sms); ++ onSms: async sms => { ++ incoming.onSms(sms); + await sms.sendResp({ smsId: answeredId }); +- })); ++ }, + }); + const { session } = await connect(t, smpp); + + assert.ok(session); + + const answered = await submitTo(session, '46709771337', 'declined by the hook'); +- const sms = await incoming; ++ const sms = await incoming.sms; + + await session.send({ cmdName: 'enquire_link' }); + await session.unbind(); +@@ -2361,16 +2310,14 @@ describe('the server\'s onRequest hook', () => { + }); + + test('reports a hook that throws and answers nothing for it', async t => { ++ let messages = 0; + const smpp = await startServer(t, { + onRequest: () => { throw new Error('the onRequest hook exploded'); }, ++ onSms: () => { messages++; }, + }); + const failed = once(resolve => { + smpp.on('session', bound => { bound.on('sessionError', resolve); }); + }); +- let messages = 0; +- +- smpp.on('session', bound => bound.on('sms', () => { messages++; })); +- + const { session } = await connect(t, smpp, { responseTimeout: 300 }); + + assert.ok(session); +@@ -2384,16 +2331,14 @@ describe('the server\'s onRequest hook', () => { + }); + + test('reports a hook that rejects and answers nothing for it', async t => { ++ let messages = 0; + const smpp = await startServer(t, { + onRequest: () => Promise.reject(new Error('the onRequest hook rejected')), ++ onSms: () => { messages++; }, + }); + const failed = once(resolve => { + smpp.on('session', bound => { bound.on('sessionError', resolve); }); + }); +- let messages = 0; +- +- smpp.on('session', bound => bound.on('sms', () => { messages++; })); +- + const { session } = await connect(t, smpp, { responseTimeout: 300 }); + + assert.ok(session); +diff --git a/test/tls.test.ts b/test/tls.test.ts +index 1e311f4..8e3fc14 100644 +--- a/test/tls.test.ts ++++ b/test/tls.test.ts +@@ -104,8 +104,9 @@ function createCertificate(): { cert: string; key: string } { + + const certificate = createCertificate(); + +-async function startServer(t: TestContext): Promise { ++async function startServer(t: TestContext, onSms: (sms: Sms) => void = () => undefined): Promise { + const { err, server: smpp } = await server({ ++ onSms, + port: 0, + tls: { cert: certificate.cert, key: certificate.key }, + }); +@@ -123,10 +124,9 @@ function once(register: (resolve: (value: T) => void) => void): Promise { + + describe('tls', () => { + test('binds over a verified handshake and delivers an SMS', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); ++ let deliver: (sms: Sms) => void = () => undefined; ++ const incoming = once(resolve => { deliver = resolve; }); ++ const smpp = await startServer(t, sms => { deliver(sms); }); + const { err, session } = await client({ + host, + port: smpp.port, +diff --git a/test/unsendable.test.ts b/test/unsendable.test.ts +index 3108910..3383ca5 100644 +--- a/test/unsendable.test.ts ++++ b/test/unsendable.test.ts +@@ -56,12 +56,12 @@ describe('an alphabet the caller named that cannot carry the message', () => { + }); + + // Å is in the GSM table at 0x0E; ï is the one the encoder flattened to a space. +- test('refuses an ASCII send of a character GSM 03.38 has no code for, naming that one', async () => { ++ test('refuses a GSM send of a character GSM 03.38 has no code for, naming that one', async () => { + const attempts: PduObjectInput[] = []; +- const sent = await submitSms(recordingDeps(attempts), { encoding: 'ASCII', from, message: 'Åsa naïve', to }); ++ const sent = await submitSms(recordingDeps(attempts), { encoding: 'GSM', from, message: 'Åsa naïve', to }); + + assert.ok(sent.err instanceof Error); +- assert.match(sent.err.message, /ASCII/); ++ assert.match(sent.err.message, /GSM/); + assert.match(sent.err.message, /"ï"/); + assert.match(sent.err.message, /U\+00EF/); + assert.match(sent.err.message, /index 6/); +@@ -151,7 +151,7 @@ describe('a body the PDU\'s own data_coding cannot carry', () => { + }); + + assert.ok(built.err instanceof Error, String(dataCoding)); +- assert.match(built.err.message, /ASCII/); ++ assert.match(built.err.message, /GSM/); + assert.match(built.err.message, /"ï"/); + assert.match(built.err.message, /U\+00EF/); + assert.match(built.err.message, /index 6/); +diff --git a/todo.md b/todo.md +index 3e5b9c7..7b9d3ed 100644 +--- a/todo.md ++++ b/todo.md +@@ -25,12 +25,13 @@ const { err, session } = await client({ host, password, port, username }); + const { err: sendErr, pduObjs, smsIds } = await session.sendSms({ dlr, from, message, to }); + await session.unbind(); + +-const { err: serverErr, server: smpp } = await server({ authenticate, port }); +-smpp.on('session', session => { +- session.on('sms', async sms => { ++const { err: serverErr, server: smpp } = await server({ ++ authenticate, ++ onSms: async sms => { + await sms.sendResp(); + if (sms.dlr) await sms.sendDlr('DELIVERED'); +- }); ++ }, ++ port, + }); + await smpp.close(); + ``` +@@ -150,7 +151,7 @@ refuses to delete its default branch. + dependency versions.** The mocha suite is gone; `node:test` on Node 18 to 26. + - [x] **Close [#33](https://github.com/larvit/larvitsmpp/issues/33), large inbound text arrives as + raw `Buffer` segments.** `IncomingRequests` reassembles a UDH-carrying `deliver_sm` into one +- `sms` event. ++ message for `onSms`. + - [x] **Close [#68](https://github.com/larvit/larvitsmpp/pull/68), a pull request for `message_id` + in `submit_sm_resp` and spec DLR codes.** All four hold: `sendResp()` always answers a + `message_id`, per segment; `stat:UNDELIV` is the 7-character code. Credit the reporter — the +@@ -216,13 +217,6 @@ below. + + ### Correctness + +-- [ ] **Refuse to open a link that dropped while its rebind was answered.** A peer sending +- `bind_resp` and FIN together can tear the link down before `comeBackUp()` resumes; it then +- calls `link.open()` on a `down` link, `resetTimers()` skips, and `attempt()` reports success, +- so the session is `up` on a destroyed socket with nothing to reconnect it until `close()`. +- `open()` accepting only `binding`, and `comeBackUp()` returning an err when the link is no +- longer attached, lets the loop retry. Unreproduced; from the stability review of #48. +- + - [ ] **Register a multipart send's receipt merge before its segments go out.** `Session.sendSms()` + calls `dlrMerger.expect()` only once `submitSms()` resolves, after the last segment's response, + so a receipt for an early segment that arrives first is logged at `debug` as naming no merge, +@@ -273,10 +267,6 @@ below. + which throws on a null-prototype object; in `run()` that lands as an unhandled rejection from + a `void`ed promise, against hard rule 1. From the 2026-09-28 scoring run. + +-- [ ] **Leave `sms.smsId` alone when `sendResp()` fails.** `sms.ts` assigns `options.smsId` before +- the lost-link check and before the write, so a failed answer still renames the message and a +- later `sendDlr()` names an id the peer was never given. From the 2026-09-28 scoring run. +- + - [ ] **Refuse a timeout past 2³¹−1 ms, as `connectTimeout` already is.** `checkLimits()` bounds + `responseTimeout`, `idleTimeout`, `shutdownTimeout` and `reassemblyTimeout` from below only, + and Node fires a larger delay after 1 ms. From the 2026-09-28 scoring run. +@@ -285,11 +275,6 @@ below. + so `"\x1B("` is detected as GSM, goes out as 0x1B 0x28 and arrives as `{`. From the + 2026-09-28 scoring run. + +-- [ ] **Count an `sms` listener that throws as one giving up, as a rejection already is.** A +- synchronous throw makes `Session.emit` return false, and `HeldMessages.offer()` then releases +- the hold at once, so a drain stops waiting on an async listener still answering beside it. +- Goal 2. From the stability review of #45. +- + ### Throughput — goal 6, and the default window is where we are slowest + + - [ ] **Close the gap to jsmpp at `maxOutstanding: 10`.** Measured 2026-09-20 against the same sink, +@@ -301,12 +286,6 @@ below. + + ### Shape — 6 today, and the gate is 7 + +-- [ ] **Answer "is this a bind command" in one place.** `bindCommands` (read by +- `incoming-requests.ts`, `outgoing-requests.ts` and `test/session.test.ts`) and +- `bindTypeFromCommand()` (read by `server.ts` and `checkedBind()`) each list the three bind +- commands, so a fourth added to one is missed by the other. Derive the list from the function, +- or the reverse. From the stability review of #42. +- + - [ ] **Group `src/` into a second level, and retire whichever record loses.** 34 files on one + plane, where `src/defs/` at 7 proves the shape is known one level down. `docs/decisions.md` + says "`src/` stays flat until a module has to move for another reason. Valid while that map is +@@ -320,18 +299,6 @@ below. + a question nor a module — it names the rest — and AGENTS.md's own convention forbids exactly + that. `max-lines` covers `src/**` only, so nothing has stopped it growing. + +-- [ ] **Collapse the three objects named `defaults`.** `client.ts`, `server.ts` and +- `session-options.ts` each export or hold one; `port: 2775` is written twice and the idle +- timeout is derived two ways to the same 40 000, and 64 MiB is both `defaultMaxOctets` and +- `defaults.maxHeldOctets`. "What is the default for X" has three answers +- depending on the entrypoint, and nothing fails when they drift. Named by both architects as the +- most likely first bug a new contributor ships. +- +-- [ ] **Give `hold` one meaning, and rename `IncomingRequests.refusing` for what it does.** +- `LinkLife.hold()` is a request's budget waiting for a link, `HeldMessages.hold()` a message the +- application owes an answer; `refusing` decides no refusal — `held.full()` does — and only makes +- the warn and info lines fire once each way. From the 2026-09-29 scoring run. +- + - [ ] **Rename `EncodingName`'s `ASCII` to `GSM7`, with `ASCII` a deprecated alias for one minor.** + It is GSM 03.38, where `$` is 0x02 and `@` is 0x00, and `segmentUnits.ASCII = 153` is a septet + budget under a name that says octets. The 2026-09-09 decision removed `consts.ENCODING.ASCII` diff --git a/docs/comprehension-rewrite/drafts/draft-e.patch b/docs/comprehension-rewrite/drafts/draft-e.patch new file mode 100644 index 0000000..6746908 --- /dev/null +++ b/docs/comprehension-rewrite/drafts/draft-e.patch @@ -0,0 +1,7694 @@ +diff --git a/AGENTS.md b/AGENTS.md +index 882bb24..ae676cc 100644 +--- a/AGENTS.md ++++ b/AGENTS.md +@@ -38,20 +38,23 @@ These are not preferences. Breaking one is a defect. + ``` + src/ + index.ts Public surface. Named exports only, no default export. +- client.ts client() -> { err, session } ++ client.ts client() -> { err, session }, and the first-connect retry of reconnect.fromStart + server.ts server() -> { err, server }, server owns the listener + close() +- session.ts Session: the socket's life, dispatch, events, and the collaborators below +- sms.ts The live handle emitted as the 'sms' event (sendResp/sendDlr) ++ session.ts Session: the public methods and events, and what each life transition does to the collaborators below ++ session-life.ts SessionLife: the one state value, every transition, and the reconnect loop as the `down` state ++ sms.ts The Sms an onSms handler gets (sendResp/sendDlr), and the one record of whether it was answered ++ backoff.ts Backoff: the wait before each connect attempt + concat.ts How a PDU says it is a segment: its UDH, or the sar_* TLVs ++ defaults.ts Every default, grouped by the surface that fills it + dlr.ts Delivery receipts: text and TLV parsing, receipt status codes + dlr-merger.ts DlrMerger: per-segment receipts counted into one MessageDlr + 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 ++ expiring-groups.ts ExpiringGroups: the capped, weighed, expiring store DlrMerger, HandledMessages and Reassembler share ++ handled-messages.ts HandledMessages: the messages whose onSms is running, counted, waited on, and answered once it settles + idle-waiters.ts IdleWaiters: waiting for a count to fall to zero, and what is left of a budget + incoming-requests.ts Every request the peer sends: messages, receipts, links, unknown commands +- 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-waiters.ts LinkWaiters: the requests with no bound link to go out on + 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 +@@ -62,12 +65,11 @@ src/ + 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 + result.ts Result — the shape every fallible call returns + 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, OnSms, bind direction and the option checks + 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 +@@ -83,9 +85,11 @@ src/ + ``` + + Imports point one way: `defs` knows nothing above it but `result.ts`, `pdu` uses `defs`, `session` +-uses `pdu`, and `client`/`server` use `session`. The ways back up are the `Session` handed to +-`createSms()`, `HeldMessages` and `IncomingRequests`, which call back into it, and to `OnRequest` +-and `onConnected` in `session-options.ts`, all imported as a type only. ++uses `pdu`, and `client`/`server` use `session`. `SessionLife` reaches the rest of the session only ++through the `LifeEffects` it is handed, and `OutgoingRequests` reads its state through a function. ++The ways back up are the `Session` handed to `createSms()` and `IncomingRequests` — because ++`Sms.session` and `OnRequest` are public and name it — and `OnRequest`, `OnSms` and `onConnected` in ++`session-options.ts`, all imported as a type only. + + **Parameter order is wire order.** The key order inside `cmds.*.params` is the order the fields are + written to and read from the buffer. Never sort those alphabetically — the alphabetical-ordering +@@ -288,13 +292,14 @@ this is not a changelog. + - A stream this library cannot frame is a dead link; one PDU it cannot parse is not. + - A deliberate shutdown drains; an unusable link and an abort do not. + - `sendSms()` puts every segment of a message on the wire together. +-- Every segment of a concatenated message is answered as it arrives, so `sendResp()` on one is the +- application's own signal rather than the peer's answer. ++- Every segment of a concatenated message is answered as it arrives, so `sendResp()` on one is ++ nothing to do. + - `server()` composes the application's `onRequest` after its own bind handling, and offers it every + request that handling did not answer. +-- The drain waits on the messages the application holds, and `sendResp()` is what says it is done +- with one. +-- The drain's wait on the application ignores `shutdownTimeout: 0`. ++- An inbound message goes to `onSms`, the handler's promise is the hold on it, and the library ++ answers it once the handler has settled. ++- A session with no `onSms` refuses every inbound message with the retry status and reports each on ++ `sessionError`. + - What the application holds unanswered is capped on constants, and a message past the cap is + refused. + - A store at its bound answers `ESME_RTHROTTLED` to a submission and `ESME_RX_T_APPN` to a delivery, +@@ -304,13 +309,15 @@ this is not a changelog. + - A message id base is merged at most once. + - A send that never reached the socket waits for the next link; one that did is counted, not resent. + - A send queued for a send-window slot is bounded by the caller's `signal`, and by nothing else. +-- One owner decides whether a link can carry a request, and a bind is what makes it one. ++- The session's life is one state value, every transition is in `SessionLife.enter()`, and the ++ reconnect loop is the `down` state. + + ### [Internals and tests](docs/decisions.md#internals-and-tests) + ++- Every default lives in `defaults.ts`, grouped by the surface that fills it. + - 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 `LinkWaiters`, `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/CHANGELOG.md b/CHANGELOG.md +index 7cb5ff7..83cfde1 100644 +--- a/CHANGELOG.md ++++ b/CHANGELOG.md +@@ -2,6 +2,24 @@ + + ## 0.6.0 (unreleased) + ++- **Inbound messages go to an `onSms` handler option**, on `client()`, `server()` and `Session`; the ++ `sms` event is gone. A message is answered `ESME_ROK` under `sms.smsId` when the handler returns, ++ unless `sendResp()` answered it first, and refused with a retry status (`ESME_RTHROTTLED` on a ++ submission, `ESME_RX_T_APPN` on a delivery) when the handler throws or rejects before answering, ++ or when the session has no `onSms` at all. `close()` waits for the handlers still running, up to ++ `shutdownTimeout`; `sendResp()` and `sendDlr()` still go out during that wait. `sms.answered` says ++ whether the peer has been answered, and replaces `answeredOnArrival`; a `sendResp()` that would ++ change an answer already given returns `err`, and one asking for the same answer does nothing. ++ `shutdownTimeout: 0` waits for a handler as long as it runs, bounded by the five minutes past which ++ one is no longer counted, where it used to give the messages `responseTimeout`. ++- **`encoding: 'ASCII'` is `encoding: 'GSM7'`**, the alphabet it always wrote; `encodings.GSM7`, ++ `dataCodingByEncoding.GSM7` and what `detect()`, `encodeMessage()` and `encodingByDataCoding()` ++ return follow. `'ASCII'` is refused by name. ++- `session.state` reads where the session is: `connected`, `bound`, `closing`, `down` or `ended`, ++ the `LinkState` type. A `send()` on a session you constructed yourself waits for `bound()` before ++ it goes out, up to `responseTimeout`, since a link not yet bound refuses it; a bind goes out at once. ++- A `sendResp()` the wire did not carry leaves `sms.smsId` as it was, so a later `sendDlr()` names ++ no id the peer was never given. + - `client()` now bounds each connect attempt at 10 seconds, the TLS handshake included, and reports + one that expires as an ordinary connect failure, so `reconnect` retries it on its usual backoff. + A connect previously waited the operating system out, around 130 s on Linux against a host that +@@ -42,9 +60,8 @@ + the cap is lost, since its segments were already answered. + - A message arriving while the application holds 1000 unanswered, or 64 MiB of them counted the way + `maxOctets` counts segments, is refused with `ESME_RTHROTTLED` (`ESME_RX_T_APPN` on a +- delivery), so the peer keeps it and retries. **Call `sendResp()` on every `sms`, multipart +- included**: 1000 left unanswered now stop inbound traffic for up to five minutes, where the oldest +- used to be dropped with a warning. ++ delivery), so the peer keeps it and retries. 1000 messages whose handler has not returned now stop ++ inbound traffic for up to five minutes, where the oldest used to be dropped with a warning. + - A `submit_sm` segment the reassembly buffer has no room for is refused with `ESME_RTHROTTLED`, + where it was `ESME_RMSGQFUL`. + - `server()` refuses a `maxOctets` below 1 or not a whole number, `Infinity` included, like its +diff --git a/DESIGN.md b/DESIGN.md +new file mode 100644 +index 0000000..e6ea437 +--- /dev/null ++++ b/DESIGN.md +@@ -0,0 +1,89 @@ ++# Draft E: `onSms` holds the message, the session's life is one machine ++ ++Rounds one and two showed two caps, the held-message contract and the lifecycle. This draft removes ++both at once, goal-2 safe. ++ ++## The contract, as an implementer sees it ++ ++- **Receiving.** `onSms: sms => …`, an option on `client()`, `server()` and `Session`. The message is ++ held while the handler's promise runs: it counts toward the bound (1000, 64 MiB) and `close()` ++ waits for it, up to `shutdownTimeout`. A handler running five minutes is no longer waited for. ++- **Answering, in one sentence.** The message is answered `ESME_ROK` under `sms.smsId` when the ++ handler returns, unless `sms.sendResp()` answered it first; a handler that throws has it refused ++ with the retry status, so the peer sends it again. No `onSms`: every message is refused that way and ++ reported on `sessionError`. `sendResp({ smsId, status })` names the id or refuses; an answer already ++ given cannot change (`err`), asking for the same one again does nothing. `sms.answered` says where ++ you are; a multipart message arrives with it already true. ++- **Receipts.** `sms.sendDlr()` goes out where it is called, after `sendResp()` when written after ++ it, and during a drain too. No timing rule. ++- **Sending and shutdown.** `sendSms()`/`send()` unchanged; `close()` refuses them, waits for the ++ handlers, then the requests on the wire, then tears down. `session.state` is public, read-only. ++- **Names.** `encoding: 'GSM7'` (`'ASCII'` refused). README carries a glossary under "SMPP terms". ++ ++## The lifecycle, in `session-life.ts` ++ ++``` ++connected --bound()---------> bound connected: a socket with no bind yet; carries a bind only ++connected --close()---------> ended ++connected --link lost-------> down | ended down where a reconnect policy exists ++bound -----close()/unbind()-> closing draining; sends refused, receipts still go out ++bound -----link lost--------> down | ended ++closing ---drained----------> ended ++closing ---link lost--------> ended ++down ------socket opened----> connected the policy rebinds, which is what reaches bound() ++down ------attempt failed---> down backs off (Backoff) and waits again ++down ------close()----------> ended ++``` ++ ++One value, `SessionLife.state`; one method, `enter()`, does every transition's effects through a ++five-function `LifeEffects` seam; a transition asked of a state that has none is ignored; every event ++is emitted last, after state and effects, so a listener that calls `close()` from `disconnected` ++re-enters harmlessly (tested). The retry timer is the `down` state's and dies with it, so no stopped ++flag exists; `attempt()` is the one continuation returning into the machine after an await, and it ++checks the state and link it came back to. ++ ++## Who owns which state ++ ++| Module | Owns | ++| --- | --- | ++| `session-life.ts` | `state`, the retry timer, `Backoff`, the link count (second bind onwards is `reconnected`). | ++| `session.ts` | The public API; the `LifeEffects` closures (the only place a transition touches collaborators); `bind`. | ++| `outgoing-requests.ts` | `request()`: one path — link wait (`LinkWaiters`), window, attempt, retry only while `down`. `requestOnLink()`: a bind or unbind, straight out. Reads `state` through a function, copies nothing. | ++| `incoming-requests.ts` | Routing, synchronous but for the hook and the peer's `unbind`. Answers through an `answer` closure, receipts through `send`; holds `session` because `Sms.session` and `OnRequest` are public. | ++| `handled-messages.ts` | The running handlers: count, weight, deadline, `idle()` for the drain, and the answer after settle. | ++| `sms.ts` | The one record of "answered" (`{ smsId, status }`), read by `answered`, written by `sendResp()`. | ++| `defaults.ts` | Every default, grouped `client`/`reconnect`/`server`/`session`. | ++| `backoff.ts` | The delay sequence, shared by the machine and `client()`'s first-connect retry. | ++ ++## Deleted ++ ++`link-life.ts` (`LinkLife`, seven predicates, `generation()`), `reconnect-loop.ts` (and its `halted`), ++`held-messages.ts` (`HeldMessages`, `MessageHold`, the `WeakMap`, `setImmediate`, the listener count), ++`Session.linkLost/end/stop/emitClose/teardown/onClose/comeBackUp/attach/resetTimers/answering`, the ++`captureRejections` route into the hold, `OutgoingRequests.requestPastDrain/requestOnCurrentLink`, ++`IncomingRequests.refusing`, the `sms` event, `answeredOnArrival`, four `defaults` objects and three ++lone constants, `EncodingName 'ASCII'`, `DlrMerger.close()` (now `spend()`), and `client.ts`'s ++"close() must reach stop() before its first await". ++ ++## Tests ++ ++`docker compose run --rm node npm test`: lint and typecheck clean, **517 tests, 517 pass** (main: 512). ++Changed, never weaker on the wire or goal 2: ++ ++- Everywhere: `session.on('sms', …)` → the `onSms` option; an `inbox()` helper hands each message to ++ the test and stays in the handler until `answer()`/`release()`, a `stuck` handler never returns. ++- `LinkLife` (6) → `SessionLife` (7): the table, drain order, reconnect round trip, re-entrant close, ++ backoff, one attempt at a time, throwing connect. `ReconnectLoop` (4) → those plus a `Session`-level ++ "leaves no socket open when coming back up fails". `held message bounds` (6) → `handled message ++ bounds` (9): exits are handler return, sweep, clear; added answer-on-return, refuse-on-throw, ++ refuse-with-no-handler. `sendResp()`: added "answers once, and refuses to change an answer". ++- `graceful shutdown`: messages read `still being handled`; "falls back to responseTimeout at 0" → ++ "waits for a handler as long as it runs"; "a message no listener took" → "no handler takes is ++ refused at once"; "a listener that rejected" → "handler … refused for it"; "waits for the listener ++ still working when another rejected" → "keeps waiting for a handler that answered and is still ++ working"; the client of "still ends when the message half…" and "counts the segments queued…" holds ++ with `stuck`, since nothing is unanswered by default any more. ++- "refuses to answer a message whose link went…": the answered one is now nothing to do; "refuses an ++ id and a refusing status…" asserts `already answered under id`; throwing/rejecting listener tests ++ assert the peer's `ESME_RTHROTTLED`; `'ASCII'` → `'GSM7'`; `readme.test.ts` mirrors the new ++ examples; `interop-tests/` and `benchmarks/` typecheck (not run here). +diff --git a/MIGRATION-NOTES.md b/MIGRATION-NOTES.md +new file mode 100644 +index 0000000..857a941 +--- /dev/null ++++ b/MIGRATION-NOTES.md +@@ -0,0 +1,19 @@ ++# Breaking changes for a 0.5.0 user ++ ++| Was | Now | ++| --- | --- | ++| `session.on('sms', async sms => { … })` | `client({ onSms: sms => { … } })`, `server({ onSms })` or `new Session({ onSms, sock })`: one handler, given at construction. | ++| `smpp.on('session', s => s.on('sms', …))` | `server({ onSms: sms => { … } })`; `sms.session` is the session the message came in on. | ++| `await sms.sendResp()` at the end of the handler | Delete it: the message is answered `ESME_ROK` under `sms.smsId` when the handler returns. Keep it where the answer must come first, or with `{ smsId, status }`. | ++| A handler that returns without answering leaves the peer waiting | It answers `ESME_ROK`; keep the handler's promise pending for as long as the message should stay unanswered. | ++| A listener that throws or rejects leaves the message unanswered | The message is refused (`ESME_RTHROTTLED` on a submission, `ESME_RX_T_APPN` on a delivery) so the peer retries; catch what you want answered otherwise. | ++| No `sms` listener leaves every message unanswered | No `onSms` refuses every message the same way and reports each on `sessionError`; give a receiving session a handler. | ++| `if (sms.answeredOnArrival) …` | `if (sms.answered) …`: true on arrival for a multipart message, and after `sendResp()`. | ++| `sendResp()` twice, or `sendResp({ status })` after `sendResp()` | The same answer again is `{}`; a different one is `err`. Answer once. | ++| `close()` waits until `sendResp()` reaches the wire | It waits until the handler returns; `err.message` reads `… N message(s) still being handled`. | ++| `sendDlr()` straight after `sendResp()`, in the same turn, to pass a drain | `sendDlr()` goes out from a running handler whenever; the rule is gone. | ++| `shutdownTimeout: 0` gives the messages `responseTimeout` | It waits for a handler as long as it runs, up to the five minutes past which one is no longer counted. | ++| `encoding: 'ASCII'` | `encoding: 'GSM7'`; likewise `encodings.GSM7`, `dataCodingByEncoding.GSM7`, `encodeMessage(…).encoding === 'GSM7'`. `'ASCII'` is refused by name. | ++| `SessionEvents` has an `sms` key | It has none; a listener typed against it moves to `OnSms`. | ++| `new Session({ sock })` then `session.send({ cmdName: 'submit_sm' })` | Call `session.bound(bindType, version)` first (or send the bind through `send()` and record its answer): a send waits for the bind, up to `responseTimeout`. | ++| A `sendResp()` that fails still renames `sms.smsId` | It leaves `sms.smsId` as it was; read it after a successful answer. | +diff --git a/MIGRATION.md b/MIGRATION.md +index 0296ede..6b05b0b 100644 +--- a/MIGRATION.md ++++ b/MIGRATION.md +@@ -11,6 +11,9 @@ shape is the same, connect, send, listen for delivery reports, with callbacks re + `close()`, or the socket outlives the call. + - **`server()` resolves once, when it is listening**, with a handle carrying `close()`, `port` and + a `session` event. It no longer calls back once per connection. ++- **The `sms` event is the `onSms` option**, on `client()`, `server()` and `Session`: ++ `onSms: sms => { … }`. The message is answered `ESME_ROK` once the handler returns, or earlier by ++ `sendResp()`; a handler that throws has it refused with a retry status. + - **The id a message is answered with goes to `sendResp({ smsId })`.** `sms.smsId` is read-only: the + id the segments were answered with, the id `sendResp()` was given, or the generated UUID v7. + Assigning to it throws a `TypeError` in strict-mode code (every ES module, and any file under +@@ -44,7 +47,8 @@ shape is the same, connect, send, listen for delivery reports, with callbacks re + - **`log`** takes any object with `debug`, `error`, `info`, `verbose` and `warn` methods instead of + a `larvitutils` one, and is silent by default: [README](README.md#logging). + - **`consts.ENCODING.ASCII` is gone**; the same entry is `consts.ENCODING.IA5`, the other name SMPP +- 3.4 5.2.19 gives 0x01. `dataCodingByEncoding` is the alphabet `sendSms()` writes, which is 0x00. ++ 3.4 5.2.19 gives 0x01. The `encoding` option's GSM 03.38 alphabet is `GSM7`, and ++ `dataCodingByEncoding.GSM7` is what `sendSms()` writes it under, which is 0x00. + + ## Behaviour that changed on the wire + +@@ -85,7 +89,7 @@ have for these: + directions. 0.4.0 kept only the last one it read. + - A body carried in the `message_payload` TLV was ignored, so the message arrived empty, and a + `data_sm` was answered `ESME_RINVCMDID`, so a receipt thrown on one was lost silently. Both reach +- the application now: a receipt as `dlr`, answered for you, and a message as `sms` for you to answer. ++ the application now: a receipt as `dlr`, answered for you, and a message to `onSms`. + - A long message segmented by the `sar_msg_ref_num`, `sar_total_segments` and `sar_segment_seqnum` + TLVs rather than a user data header was never reassembled, so each segment arrived as its own + message. Both spellings reassemble now. +diff --git a/README.md b/README.md +index 9ff2b7d..2cc17ca 100644 +--- a/README.md ++++ b/README.md +@@ -10,7 +10,8 @@ window, long messages and delivery receipts. TypeScript, ESM, no dependencies. + - **Send window.** 10 requests in flight; further sends queue instead of overrunning the SMSC. + - **Long messages.** Split on send, reassembled on receive, in both the UDH and `sar_*` spellings. + - **Delivery receipts.** Read from TLVs or from receipt text, matched to the ids you were given. +-- **Graceful shutdown.** `close()` waits for what is in flight, so neither end has to guess. ++- **Graceful shutdown.** `close()` waits for your message handlers and for what is in flight, so ++ neither end has to guess. + - **Never throws.** Every fallible call resolves to `{ err?, … }`. + - **Interoperable.** Tested as a client against Jasmin and SMPPSim, and as a server against Kannel, + jsmpp, Cloudhopper, python-smpplib and php-smpp: +@@ -19,7 +20,8 @@ window, long messages and delivery receipts. TypeScript, ESM, no dependencies. + [Install](#install) · [Send an SMS](#send-an-sms) · [Delivery reports](#delivery-reports) · + [Receive SMS](#receive-sms) · [Run an SMPP server](#run-an-smpp-server) · [Errors](#errors) · + [Client options](#client-options) · [Server options](#server-options) · +-[Send options](#send-options) · [Session](#session) · [Receiving in depth](#receiving-in-depth) · ++[Send options](#send-options) · [SMPP terms](#smpp-terms) · [Session](#session) · ++[Receiving in depth](#receiving-in-depth) · + [Server in depth](#server-in-depth) · [Logging](#logging) · + [PDUs and the low-level API](#pdus-and-the-low-level-api) · + [Migrating from 0.4.0](#migrating-from-larvitsmpp-040) · [Goals](#goals) · +@@ -92,34 +94,33 @@ receipts into one, and SMSCs that write ids in two notations: [Delivery receipts + + ## Receive SMS + +-A `receiver` or `transceiver` client gets mobile-originated messages as `sms` events: ++A `receiver` or `transceiver` client hands mobile-originated messages to its `onSms` handler: + + ```javascript +-session.on('sms', async sms => { +- // sms.from, sms.to, sms.message +- await sms.sendResp(); ++const { err, session } = await client({ ++ onSms: sms => { ++ // sms.from, sms.to, sms.message; answered ESME_ROK once this returns ++ }, + }); + ``` + +-Call `sendResp()` for every message, multipart included: until you do, it counts toward the bound +-past which the peer's messages are refused. Delivery receipts reach you as `dlr` events, not here. A +-multipart message arrives reassembled and already answered segment by segment, so `sendResp()` there +-puts nothing on the wire and releases it: [Receiving in depth](#receiving-in-depth). ++The message is answered when your handler returns: `ESME_ROK` under `sms.smsId`, or the answer ++you gave first with `sms.sendResp()`. Until it returns, the message counts toward the bound past ++which the peer's messages are refused, and `close()` waits for it. A handler that throws has the ++message refused with a retry status, so the peer sends it again. Delivery receipts reach you as ++`dlr` events, not here: [Receiving in depth](#receiving-in-depth). + + ## Run an SMPP server + + ```javascript + import { server } from '@larvit/smpp'; + +-const { err, server: smpp } = await server(); +-if (err) throw err; +- +-smpp.on('session', session => { +- session.on('sms', async sms => { +- // sms.from, sms.to, sms.message, sms.dlr +- await sms.sendResp(); +- }); ++const { err, server: smpp } = await server({ ++ onSms: sms => { ++ // sms.from, sms.to, sms.message, sms.dlr, sms.session ++ }, + }); ++if (err) throw err; + ``` + + With authentication and delivery reports: +@@ -134,34 +135,32 @@ const { err, server: smpp } = await server({ + + return { userData: { userId: 123 } }; + }, +-}); +-if (err) throw err; +- +-smpp.on('session', session => { +- session.on('sms', async sms => { +- if (sms.answeredOnArrival) { +- await sms.sendResp(); // multipart: already answered per segment; this only releases the shutdown drain +- } else { +- // no args: ESME_ROK + generated id; or sendResp({ smsId, status: 'ESME_RMSGQFUL' }) +- await sms.sendResp(); +- } ++ onSms: async sms => { ++ // sms.session.userData is what authenticate returned for this peer ++ // no args: ESME_ROK + generated id; or sendResp({ smsId, status: 'ESME_RMSGQFUL' }) ++ await sms.sendResp(); + + if (sms.dlr) { + await sms.sendDlr(); // same as sms.sendDlr('DELIVERED') + } +- }); ++ }, + }); ++if (err) throw err; + + console.log(smpp.port); // the port actually bound, useful when 0 was requested + await smpp.close(); // stop listening, then drain and close every live session + ``` + +-- `sendResp()` answers `ESME_ROK` with a generated UUID v7 as the message id. +- `sendResp({ smsId, status })` names the id or refuses the message. ++- `sendResp()` answers `ESME_ROK` with a generated UUID v7 as the message id, now rather than when ++ the handler returns. `sendResp({ smsId, status })` names the id or refuses the message. An answer ++ cannot change: a second call asking for the same answer does nothing, one asking for another ++ returns `err`. + - `sms.dlr` is true where the sender asked for a receipt. `sendDlr()` reports `DELIVERED`, +- `sendDlr('UNDELIVERABLE')` any other state: [Server in depth](#server-in-depth). +-- A message that arrived in several segments was answered as they arrived, so `sendResp()` there +- takes no `smsId` or refusing `status`. `sms.answeredOnArrival` says which case you are in. ++ `sendDlr('UNDELIVERABLE')` any other state, and goes out after the response when called after ++ `sendResp()` as above: [Server in depth](#server-in-depth). ++- A message that arrived in several segments was answered as they arrived, so `sms.answered` is ++ already true and `sendResp()` there takes no `smsId` or refusing `status`. ++- `smpp.close()` waits for every running `onSms` before it closes a session, so nothing is cut off. + + ## Errors + +@@ -182,8 +181,8 @@ Neither is named `error`, because Node throws on an unhandled `error` event. + | Kind | Type | | + | --- | --- | --- | + | A PDU the peer sent that the codec could not read. The link stays up; only that PDU is lost. | `PduRefusedError` | Count it. | +-| A concatenated message given up on before it was whole. Its segments were answered, so the peer will not resend, and no `sms` fired for it. | `Error` | Count it as lost traffic. | +-| The session or socket failing, or a hook or listener that threw or rejected. | `Error` | Alert. | ++| A concatenated message given up on before it was whole. Its segments were answered, so the peer will not resend, and it never reached `onSms`. | `Error` | Count it as lost traffic. | ++| The session or socket failing, or a hook, handler or listener that threw or rejected. | `Error` | Alert. | + + The last two are told apart by message text only, so this alerts on both: + +@@ -231,8 +230,9 @@ All optional. Timeouts and delays are milliseconds. + | `enquireLinkInterval` | `20000` | Interval between `enquire_link` on a quiet link. | + | `idleTimeout` | `2 × enquireLinkInterval` | Give up on a link the peer has stopped answering, and re-bind unless `reconnect` is `false`. | + | `responseTimeout` | `30000` | How long to wait for a response, and how long a send with no link waits for the next one. `0` waits forever. | +-| `shutdownTimeout` | `5000` | How long `close()` and `unbind()` wait for requests already sent and messages not yet answered. `0` waits forever for the requests, which end when the peer answers or `responseTimeout` expires, so both at `0` never ends. The messages then fall back to `responseTimeout`, or to its default where that is `0` too. | ++| `shutdownTimeout` | `5000` | How long `close()` and `unbind()` wait for `onSms` handlers still running and for requests already sent. `0` waits forever: a request ends when the peer answers or `responseTimeout` expires, so both at `0` never ends, and a handler ends when it returns or after the five minutes past which one is no longer counted. | + | `maxOutstanding` | `10` | Requests on the wire at once; further sends queue. | ++| `onSms` | refuse | `(sms) => Promise \| void`. Takes every inbound message: [Receive SMS](#receive-sms). Without one, every message is refused with a retry status and reported on `sessionError`. | + | `smsIdFormat` | — | The notation the SMSC writes message ids in, per place: `{ receipt: 'decimal', submitResp: 'hex' }`. Only where the two disagree: [Delivery receipts](#delivery-receipts). | + | `reconnect` | on | `{ minDelay, maxDelay }` retunes the backoff; `false` turns it off, so a drop ends the session; `{ fromStart: true }` retries the first connect too. | + | `log` | silent | Any object with `debug`, `error`, `info`, `verbose` and `warn` methods: [Logging](#logging). | +@@ -257,6 +257,7 @@ All optional. Timeouts are milliseconds. + | `host`, `port` | all interfaces, `2775` | Where to listen. `port: 0` takes any free port; `smpp.port` says which. | + | `authenticate` | accept everything | `({ password, session, systemId, systemType }) => false \| { userData }`, sync or async. | + | `onRequest` | none | `(session, pduObj) => true \| false`, sync or async. First refusal on every request a bound peer sends: [Server in depth](#server-in-depth). | ++| `onSms` | refuse | `(sms) => Promise \| void`. Takes every message a bound peer submits, on every session; `sms.session` says which: [Run an SMPP server](#run-an-smpp-server). | + | `systemId` | `''` | The SMSC identity returned in the bind response. | + | `interfaceVersion` | `0x34` | The SMPP version advertised in the bind response. Optional parameters are sent to a peer from `0x34` up, whatever this is set to. | + | `tls` | `false` | A `tls.TlsOptions` object with your certificate and key. A bare `true` is refused. | +@@ -298,11 +299,11 @@ refuse a non-ASCII sender of its own accord, which reaches you as a refusal such + + | `encoding` | Alphabet | Characters per SMS | Per segment of a long message | + | --- | --- | --- | --- | +-| `ASCII` | GSM 03.38 7-bit | 160 | 153 | ++| `GSM7` | GSM 03.38 7-bit | 160 | 153 | + | `LATIN1` | ISO 8859-1 | 140 | 134 | + | `UCS2` | UCS-2 | 70 | 67 | + +-- Omitted: `ASCII` where the message fits GSM 7-bit, otherwise `UCS2`. `LATIN1` only when named. ++- Omitted: `GSM7` where the message fits GSM 7-bit, otherwise `UCS2`. `LATIN1` only when named. + Any other name is refused. + - GSM extension characters (`{}[]\~^|€` and form feed) count as two, as does a character outside + the basic multilingual plane in `UCS2`. +@@ -353,13 +354,33 @@ yourself, a `Buffer` body or a stamp you formatted, passes through as written, e + field is still checked: [PDUs and the low-level API](#pdus-and-the-low-level-api). The same rule + holds for `session.send()`. + ++## SMPP terms ++ ++The words this README and the wire share, each once. ++ ++| Term | | ++| --- | --- | ++| ESME, SMSC | The two ends of a link. The ESME (external short message entity) is the application's side, the one `client()` is; the SMSC (short message service centre, or MC) is the operator's, the one `server()` stands in for. | ++| PDU | Protocol data unit: one SMPP request or response on the wire, a 16-octet header and its fields. | ++| bind | The login that opens a link, as a `transmitter`, `receiver` or `transceiver`; `system_id` and `password` are its credentials. | ++| `submit_sm`, `deliver_sm` | The message commands: an ESME submits to the SMSC, the SMSC delivers to the ESME. `data_sm` carries a message either way. | ++| DLR, receipt | A delivery receipt: the SMSC's report on a submitted message, carried on `deliver_sm` and telling the ESME the message's state. | ++| `esm_class` | The octet that says what a message is: its messaging mode, whether it is a receipt, and whether its body starts with a UDH. | ++| `data_coding` | The octet naming the body's alphabet, and for some values its message class (flash). | ++| UDH | User data header: octets at the front of a body that, for a long message, number its segments. | ++| `sar_*` | The three optional parameters that number a segment the other way, with no UDH. | ++| TLV | Tag-length-value: an optional parameter after the mandatory fields, `message_payload` and `receipted_message_id` among them. | ++| `message_id` | The id the SMSC hands out for a submitted message, and what a receipt names it by; `smsId` on this API. | ++ + ## Session + + ### Events + ++A request you must answer is a handler option: `onSms`, and `onRequest` on a server. A fact you may ++watch is an event, and nothing waits on its listeners. ++ + | Event | Fires when | + | --- | --- | +-| `sms` | An SMS arrives, reassembled if it was multipart. Carries `sendResp()`, `sendDlr()` and `smsId`. | + | `dlr` | A delivery report arrives, one per segment, with its PDU as the second argument: [Delivery receipts](#delivery-receipts). | + | `messageDlr` | Every segment of a long message sent with `dlr: true` has a final report: [Delivery receipts](#delivery-receipts). | + | `close` | The session is over and nothing will bring the link back. Fires once, whether you closed it or the link failed for good. | +@@ -374,19 +395,20 @@ holds for `session.send()`. + + `sendSms()`, `send()`, `sendReturn()`, `unbind()` and `close()`. + ++**Where the session is.** `session.state` is one of `connected` (a socket, no bind on it yet), ++`bound`, `closing` (draining), `down` (dropped, a reconnect on its way) and `ended`. The events above ++mark the transitions; nothing else changes it. ++ + **Shutdown.** `close()` and `unbind()` both: + +-1. Refuse further sends. `sendDlr()` is the one send let past, when issued straight after +- `sendResp()`; await anything in between and it races the shutdown like any other send. +-2. Wait up to `shutdownTimeout` for the requests already sent, and for every `sms` the application +- has not answered. That wait ends when `sendResp()` puts the response on the wire (or, for a +- message answered on arrival, when it is called at all), or when every listener that took the +- message has failed. Answering through `sendReturn()` instead leaves the wait running. ++1. Refuse further `sendSms()` and `send()`. `sendResp()` and `sendDlr()` still go out, because they ++ finish a message the shutdown is waiting on. ++2. Wait up to `shutdownTimeout` for every `onSms` handler still running, then for the requests ++ already sent. + 3. Tear down what is left, resolving to an `err` that says what was lost. + +-A message left unanswered for five minutes is no longer waited for. +-`close({ signal })` cuts the wait short. `unbind()` takes no signal, and waits a further +-`responseTimeout` for its own response. ++A handler still running after five minutes is no longer waited for. `close({ signal })` cuts the wait ++short. `unbind()` takes no signal, and waits a further `responseTimeout` for its own response. + + **Sends and the link.** + +@@ -426,9 +448,10 @@ const { err, pduObj } = await session.send({ + - `boundAs` and `peerInterfaceVersion` are read-only, and hold through a reconnect's gap until the + link binds again. + - `bound(bindType, declaredVersion)`: how a session you construct yourself records a bind, whichever +- end accepted it, on every link it binds. `bindType` is `receiver`, `transceiver` or `transmitter`; +- `declaredVersion` is 0-255, or `undefined` where the peer declared none. Anything else returns `err` +- and records nothing. ++ end accepted it, on every link it binds, and what lets that link carry requests: until it is called ++ a `send()` waits for it, up to `responseTimeout`, and a bind goes out at once. `bindType` is ++ `receiver`, `transceiver` or `transmitter`; `declaredVersion` is 0-255, or `undefined` where the ++ peer declared none. Anything else returns `err` and records nothing. + - An ESME wired by hand sends its own `bind_` through `session.send()`, after it is + constructed and again in `reconnect.onConnected`, and records each accepted one with + `session.bound(bindType, pduObj.tlvs.sc_interface_version?.tagValue)`, where `bindType` is the one +@@ -442,12 +465,15 @@ const { err, pduObj } = await session.send({ + each is two messages. + - **Answered on arrival.** Each segment was answered as it landed, before you see the message: + [Server in depth](#server-in-depth). +-- **Unanswered messages.** While a session holds 1000 messages you have not called `sendResp()` on, +- or 64 MiB of them counted the way `maxOctets` counts segments, every new message and segment is +- refused so the peer retries it: `ESME_RTHROTTLED` on a submission, `ESME_RX_T_APPN` on a delivery. +- No `sms` fires. Reaching the bound logs one `warn`, and the first message accepted once both are +- down to half one `info`. A message left five minutes is dropped from the count with a `warn`; a +- later `sendResp()` still answers it. None of the three is an option. ++- **Messages being handled.** While 1000 `onSms` handlers are running on a session, or the messages ++ they hold weigh 64 MiB counted the way `maxOctets` counts segments, every new message and segment ++ is refused so the peer retries it: `ESME_RTHROTTLED` on a submission, `ESME_RX_T_APPN` on a ++ delivery. The handler is not called. Reaching the bound logs one `warn`, and the first message ++ accepted once both are down to half one `info`. A handler running five minutes is dropped from the ++ count with a `warn`; its `sendResp()` still answers. None of the three is an option. ++- **A handler that fails.** One that throws or rejects reaches `sessionError`. Where it had not ++ answered, the message is refused with the same retry status, so the peer sends it again; where it ++ had, that answer stands. A session with no `onSms` refuses every message the same way. + - **Where the body is.** A body in the `message_payload` TLV, SMPP's way of carrying up to 64 KB and + the only place a `data_sm` has, reads exactly like one in `short_message`, concatenated messages + and receipts included. A PDU filling both is read from `short_message`. +@@ -505,11 +531,11 @@ even where the SMSC took some of its segments; their receipts still arrive as `d + will not send the next until the last is answered. The answer is `ESME_ROK`, unless the segment + numbers itself into no message this session can join, which refuses it, or the reassembly buffer or + the unanswered messages are at their bound, which asks the SMSC to keep it and try again. +-`sms.answeredOnArrival` says whether the message you hold was answered that way; a segment count +-cannot, since a peer may number a message one part of one. ++`sms.answered` is already true on such a message when your handler gets it; a segment count cannot ++say, since a peer may number a message one part of one. + +-- The id was fixed with the first segment, so `sendResp()` there puts nothing on the wire and +- releases the message, and returns `err` for an `smsId` or a refusing `status`. ++- The id was fixed with the first segment, so `sendResp()` there puts nothing on the wire, and ++ returns `err` for an `smsId` or a refusing `status`. + - `sms.smsId` is the base. `sendDlr()` names `-1`, `-2` and so on: the ids the + `submit_sm` responses carried. + - A `deliver_sm` is answered with no id at all, since SMPP marks that field unused, so an inbound +@@ -517,7 +543,7 @@ cannot, since a peer may number a message one part of one. + + **Refusing a request** for a reason in the request rather than the message (a full queue, an unknown + recipient, an unauthorised sender) has to land before a segment is answered. `onRequest` runs on +-every request a bound peer sends, before reassembly and before the `sms` event: ++every request a bound peer sends, before reassembly and before `onSms`: + + ```javascript + import { isCommand, server } from '@larvit/smpp'; +@@ -668,7 +694,7 @@ if (isCommand(pduObj, 'submit_sm')) { + | Receipts | `dlrFromPdu`, `parseReceipt`, `receiptCodes` | + | Time and ids | `smppDate`, `smppTime`, `uuidv7` | + | Spec tables | `cmds`, `consts`, `encodings`, `errors`, `tlvs`, `types`, the `cmdsById`, `constsById`, `errorsById` and `tlvsById` maps, and all of them grouped as `defs`. `isCommandName`, `isErrorName`, `isEncodingName`, `isTlvName`, `commandNameById` and `errorNameById` narrow a value into them. | +-| Types | Every option, result, event payload and table entry has a named type: `ClientOptions`, `ServerOptions`, `SendSmsOptions`, `SendSmsResult`, `Sms`, `Dlr`, `MessageDlr`, `Receipt`, `PduObject`, `PduHeader`, `SmppLog`, `Result` and the rest in `dist/index.d.ts`. | ++| Types | Every option, hook, result, event payload and table entry has a named type: `ClientOptions`, `ServerOptions`, `OnSms`, `LinkState`, `SendSmsOptions`, `SendSmsResult`, `Sms`, `Dlr`, `MessageDlr`, `Receipt`, `PduObject`, `PduHeader`, `SmppLog`, `Result` and the rest in `dist/index.d.ts`. | + + ## What changed per release + +diff --git a/benchmarks/smsc-sink.ts b/benchmarks/smsc-sink.ts +index 324c11e..02a2e5d 100644 +--- a/benchmarks/smsc-sink.ts ++++ b/benchmarks/smsc-sink.ts +@@ -5,22 +5,14 @@ import { server } from '../src/server.ts'; + * library's own ceiling rather than an SMSC's storage. Prints the bound port on stdout, then waits. + */ + const port = Number(process.env.PORT ?? 0); +-const { err, server: smpp } = await server({ port }); ++let answered = 0; ++const { err, server: smpp } = await server({ onSms: () => { answered++; }, port }); + + if (err) { + process.stderr.write(`sink failed to listen: ${err.message}\n`); + process.exit(1); + } + +-let answered = 0; +- +-smpp.on('session', session => { +- session.on('sms', async sms => { +- answered++; +- await sms.sendResp(); +- }); +-}); +- + smpp.on('serverError', reason => { + process.stderr.write(`sink serverError: ${reason.message}\n`); + }); +diff --git a/docs/decisions.md b/docs/decisions.md +index c245097..0f41884 100644 +--- a/docs/decisions.md ++++ b/docs/decisions.md +@@ -27,7 +27,9 @@ rule and an index of the titles below. + set.delete(session))` returns a boolean. This also settles what the drain can wait on: a listener's + own promise would be the better completion signal, and reaching it needs `listeners()`, which + cannot be re-declared the same way — Node types it invariantly enough that widening `void` to +- `unknown` is `TS2416`. Re-probed 2026-09-01; `sendResp()` stays the signal. ++ `unknown` is `TS2416`. That is why a message goes to a handler option rather than an event: the ++ handler's own promise is the completion signal a listener cannot give (under [The session's ++ life](#the-sessions-life)). + + - **`PduRefusedError` is exported, and `sessionError` names it in the event's type.** Maintainer's + call, 2026-09-05, from a product review: one event carries both a PDU the peer malformed and the +@@ -226,7 +228,7 @@ rule and an index of the titles below. + `encodingByDataCoding()` reads the alphabet off that same test rather than repeating the group + masks beside it. It is exported for the reason `concatOf()` is — an application that needs a class + other than 0 would otherwise rewrite the read this fixed. Rejected: a `messageClass` field on the +- `sms` event, which pays goal 8 for three classes nothing here acts on, where the boolean the ++ `Sms`, which pays goal 8 for three classes nothing here acts on, where the boolean the + application already had covers the one it does. Compressed text is out of scope and stays out — + nothing here implements 3GPP TS 23.042, so a compressed body reaches the application as whatever + its declared alphabet makes of it — but bit 5 does not move the class bits, so 0x30 is read as +@@ -583,23 +585,23 @@ rule and an index of the titles below. + one round trip rather than one per segment. Rejected: sending each segment once the last is + answered, which a receiver waiting for the whole message before answering would deadlock. + +-- **Every segment of a concatenated message is answered as it arrives, so `sendResp()` on one is the +- application's own signal rather than the peer's answer.** Maintainer's call, 2026-09-06, from the +- Jasmin interoperability phase: Jasmin dispatches one `submit_sm` per connector at a time and will +- not send segment 2 until segment 1 is answered, so holding a group unanswered until it was whole +- deadlocked every multi-segment message against a production gateway ++- **Every segment of a concatenated message is answered as it arrives, so `sendResp()` on one is ++ nothing to do.** Maintainer's call, 2026-09-06, from the Jasmin interoperability phase: Jasmin ++ dispatches one `submit_sm` per connector at a time and will not send segment 2 until segment 1 is ++ answered, so holding a group unanswered until it was whole deadlocked every multi-segment message ++ against a production gateway + ([interop-tests/findings/03-jasmin.md](../interop-tests/findings/03-jasmin.md)). Goal 1 has the answer + a real SMSC gives — one `message_id` per `submit_sm`, immediately — so the group's id base is + generated when it opens and each segment is answered `-`, the notation `sms-id.ts` owns + and `DlrMerger` reads back. The id is therefore fixed by the first segment, which is why an `smsId` + or a refusing `status` passed to `sendResp()` on such a message is an error rather than a silent +- no-op. `answeredOnArrival` is on `Sms` because nothing the application can compute says it, and the +- discriminant a reader would reach for instead is wrong. A message `sendResp()` still answers itself is +- untouched, and is where a caller-chosen id and a refusal live; `onRequest` is the escape hatch for +- an application that must refuse a PDU the `sms` event could not have shown it yet. `collect()` +- answers every segment it will not carry rather than leaving it unanswered, which is the same stall +- in miniature: the field that numbered it where the segment belongs to no group, the retry status +- where the segment's own arrival overran the octet cap, since a peer told that still holds it. Rejected: ++ no-op — the same rule as for any answer already given. `Sms.answered` is true on such a message ++ from the start because nothing the application can compute says it, and the discriminant a reader ++ would reach for instead, the segment count, is wrong. `onRequest` is the escape hatch for an ++ application that must refuse a PDU `onSms` could not have shown it yet. `collect()` answers every ++ segment it will not carry rather than leaving it unanswered, which is the same stall in miniature: ++ the field that numbered it where the segment belongs to no group, the retry status where the ++ segment's own arrival overran the octet cap, since a peer told that still holds it. Rejected: + answering every segment but the one that completes the group, which leaves the peer holding some + segments accepted and one refused with nothing in SMPP to retract the rest, and still cannot honour + a caller's `smsId` on the segments already gone. Rejected: a hook that mints the id per segment, +@@ -608,12 +610,12 @@ rule and an index of the titles below. + distinguishing feature is that it deadlocks. Accepted: a group given up on — expired, evicted, or + dropped with the link — is traffic the peer will not send again, so each one reaches `sessionError` + as well as the log. Rejected there: an exported `MessageLostError` carrying the group, on the +- `PduRefusedError` pattern — no `sms` ever fired for that group, so there is nothing in it the ++ `PduRefusedError` pattern — no handler ever ran for that group, so there is nothing in it the + application could act on, and goal 8 does not buy a second exported class to make a count + distinguishable. Accepted: a completing segment whose own answer the socket would not carry still + reaches the application, because the message is whole and correct and the failed answer is on + `sessionError` — a peer that re-sends after the drop is the smaller risk than dropping a message +- in hand. The answer goes out before the `sms` event either way, so a listener's own receipt can ++ in hand. The answer goes out before the handler runs either way, so a handler's own receipt can + never precede the acceptance of the message it reports on. + + - **`server()` composes the application's `onRequest` after its own bind handling, and offers it +@@ -653,23 +655,39 @@ rule and an index of the titles below. + fall-through could be gated on it, which buys a fail-open path with state and an internal contract + no other collaborator needs. + +-- **The drain waits on the messages the application holds, and `sendResp()` is what says it is done +- with one.** Maintainer's call, 2026-09-01: waiting on the send window alone tore a server session +- down while the application was still answering a `submit_sm`, so the peer timed out and re-sent — +- the duplicate goal 2 forbids, in the direction the window already covers. No completion signal was +- added to the `sms` event: `sendResp()` is what an application already calls when it is done with a +- message, so it is the one the drain waits for. Counting every inbound request until `sendReturn()` +- answered it was rejected: an `onRequest` that deliberately answers nothing would then cost a full +- `shutdownTimeout` on every close. The response reaching the wire ends the wait, so a `sendResp()` +- the library refused or the socket would not carry leaves `close()` still reporting the message the +- peer is owed. +- +-- **The drain's wait on the application ignores `shutdownTimeout: 0`.** Waiting forever is safe for +- the peer, whose every request is bounded by `responseTimeout` unless the caller set that to 0 as +- well, and unsafe for the application, which nothing bounds — `close()` is what you reach for when +- the 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 +- option's default where it is 0 as well, since neither option is an answer about the application. ++- **An inbound message goes to `onSms`, the handler's promise is the hold on it, and the library ++ answers it once the handler has settled.** Maintainer's call, 2026-09-29, from the third ++ comprehension round: under the `sms` event the hold had six exits — a listener count, a `WeakMap` ++ routing a rejection back from `Session`, a `setImmediate` a receipt relied on — and every seat ++ named it. A handler has one: its promise settles. Goal 2 settles what the answer is. A handler that ++ returned has taken the message, so answering `ESME_ROK` then is not answering before the ++ application has it; one that threw has decided nothing, so the message is refused with the retry ++ status and the peer keeps it, where answering nothing left the peer to time out and a synchronous ++ throw used to release the hold with the message unanswered. `sendResp()` stays, for the answer that ++ cannot wait for the return — an id of the application's own, a refusal, or freeing the peer's ++ window before slow work — and is what `sendDlr()` follows, so a receipt goes out after the ++ response it reports on. "Answered" is one record, inside the `Sms`: `sendResp()` writes it and ++ the runner reads it, so a handler that answered and then failed keeps its answer. Goal 4 settles the ++ bound: a running handler counts against the same 1000 and 64 MiB the unanswered message did, and a ++ handler running past five minutes stops counting rather than holding a drain forever, which is ++ also why `shutdownTimeout: 0` no longer needs a fallback for the application half. Rejected: ++ answering `ESME_ROK` on arrival and running the handler after, which loses a message the process ++ dies on — goal 2's "work the peer has no reason to send again is not dropped". Rejected: leaving a ++ message the handler returned without answering to the peer's timeout, which is the same loss one ++ retry later and a footgun besides. Rejected: the handler's return value as the answer, which has ++ nowhere to put a receipt that must follow the response. Rejected: `onSms` as an event whose ++ listeners' promises are collected — `listeners()` cannot be re-typed to admit a promise (under ++ [The public surface](#the-public-surface)), and two listeners are two answers. Valid while the ++ message-carrying commands are answered per segment. ++ ++- **A session with no `onSms` refuses every inbound message with the retry status and reports each ++ on `sessionError`.** Maintainer's call, 2026-09-29. Goal 2 settles the status: `ESME_RX_T_APPN` ++ and `ESME_RTHROTTLED` leave the message with the peer, where a permanent error would have it ++ dropped and silence would have the peer time out on it. Goal 4 settles that it is answered at all. ++ The report is per message because the state is the application's misconfiguration and every ++ refused message is traffic it meant to take. Rejected: `ESME_RX_P_APPN`, the permanent error, ++ which a peer honours by discarding. Rejected: answering `ESME_ROK`, which reports a message taken ++ that nothing took. + + - **What the application holds unanswered is capped on constants, and a message past the cap is + refused.** A bound the application cannot raise is the point: an application that answers nothing +@@ -750,18 +768,33 @@ rule and an index of the titles below. + an abort while held for a link already gives. The drain half needs nothing: `close({ signal })` already hands the signal to + `window.idle()`, and `unbind()` taking none is the shape README states. + +-- **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. +- `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. +- ++- **The session's life is one state value, every transition is in `SessionLife.enter()`, and the ++ reconnect loop is the `down` state.** Maintainer's call, 2026-09-29, from the third comprehension ++ round: a four-value phase beside a `stopped` flag, seven predicates over them read by three ++ collaborators, and four `Session` methods whose correctness hung on call order were what every ++ seat least wanted to modify, and `ReconnectLoop` carried a second stopped flag of its own. Goal 8's ++ reshapeable internals need a lifecycle a reader can hold. `connected`, `bound`, `closing`, `down` ++ and `ended` are the states; a transition asked of a state that has none is ignored, which is what ++ makes a listener that re-enters `close()` from `disconnected` harmless, and every event is emitted ++ after its state and effects are in place, so no comment has to say so. The retry timer belongs to ++ `down` and is cancelled by leaving it, so nothing else needs a stopped flag; the one continuation ++ that returns into the machine after an await, the reconnect attempt, checks the state it came ++ back to and the link it left from. `OutgoingRequests` reads the state through a function and ++ copies nothing, and a bind is what moves the link to `bound`: a request on a `connected` link ++ waits, up to `responseTimeout`, since sending it comes back `ESME_RINVBNDSTS` (goal 1). Rejected: ++ a reducer returning effects for `Session` to run, which read as a machine only until the effects ++ ran and was scored no higher. Rejected: the machine inside `Session`, which put it past the file ++ cap beside the API it serves. `client()`'s first-connect retry keeps a loop of its own over the ++ same `Backoff`, because no session exists yet to be `down`. Valid while a session is constructed ++ with a socket. + + ## Internals and tests + ++- **Every default lives in `defaults.ts`, grouped by the surface that fills it.** Maintainer's ++ call, 2026-09-29: five files each carried a `defaults` object and three more a lone constant, so ++ the README's "Default" column had no single place to be checked against. Goal 5 owns the defaults; ++ one file is what makes them reviewable as a set. ++ + - **Locality work comes before other work until a scoring run reads 7.0.** Maintainer's call, + 2026-09-27, when #30 merged under the comprehension floor at 6, 6, 7 and 6; #46, #48 and #49 + merged under it on that condition, #49 at 6, 6, 7 and 6 with Locality 5, 5, 6 and 6. Serves goal +@@ -777,7 +810,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 `LinkWaiters`, `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 +diff --git a/interop-tests/cloudhopper.test.ts b/interop-tests/cloudhopper.test.ts +index dca5b5b..b6e6e94 100644 +--- a/interop-tests/cloudhopper.test.ts ++++ b/interop-tests/cloudhopper.test.ts +@@ -41,23 +41,32 @@ async function driver(path: string, params: Record = {}): Promis + const manualTexts = new Set(); + const allSms: { session: Session; sms: Sms }[] = []; + +-function attach(session: Session): void { +- session.on('sms', sms => { +- allSms.push({ session, sms }); ++/** Stays in the handler until the test has answered the message, so the answer is the test's own. */ ++function untilAnswered(sms: Sms): Promise { ++ return new Promise(resolve => { ++ const timer = setInterval(() => { ++ if (!sms.answered) return; ++ ++ clearInterval(timer); ++ resolve(); ++ }, 20); ++ }); ++} + +- if (manualTexts.has(sms.message)) return; ++function onSms(sms: Sms): Promise { ++ allSms.push({ session: sms.session, sms }); + +- // The slow server this phase's window scenarios need: every ordinary submit is held for +- // SLOW_DELAY_MS before being answered, so a burst genuinely presses on a small window. +- void delay(SLOW_DELAY_MS).then(() => sms.sendResp()); +- }); ++ if (manualTexts.has(sms.message)) return untilAnswered(sms); ++ ++ // The slow server this phase's window scenarios need: every ordinary submit is held for ++ // SLOW_DELAY_MS before being answered, so a burst genuinely presses on a small window. ++ return delay(SLOW_DELAY_MS); + } + +-const { err: serverErr, server: smpp } = await server({ authenticate: () => true, idleTimeout: 40_000, port: SMPP_PORT }); ++const { err: serverErr, server: smpp } = await server({ authenticate: () => true, idleTimeout: 40_000, onSms, port: SMPP_PORT }); + + assert.equal(serverErr, undefined); + assert.ok(smpp); +-smpp.on('session', attach); + + const key = readFileSync('/shared-certs/server.key'); + const cert = readFileSync('/shared-certs/server.crt'); +@@ -67,13 +76,13 @@ const cert = readFileSync('/shared-certs/server.crt'); + const { err: tlsServerErr, server: tlsSmpp } = await server({ + authenticate: () => true, + idleTimeout: 40_000, ++ onSms, + port: TLS_PORT, + tls: { cert, key, maxVersion: 'TLSv1.2' }, + }); + + assert.equal(tlsServerErr, undefined); + assert.ok(tlsSmpp); +-tlsSmpp.on('session', attach); + + after(async () => { + await smpp.close(); +diff --git a/interop-tests/dumbclient.test.ts b/interop-tests/dumbclient.test.ts +index 81bc9ff..e9d6ef0 100644 +--- a/interop-tests/dumbclient.test.ts ++++ b/interop-tests/dumbclient.test.ts +@@ -105,6 +105,7 @@ const { err, server: smpp } = await server({ + authenticate: ({ systemId }) => ({ userData: { systemId } satisfies ScenarioUserData }), + idleTimeout: 40_000, + log, ++ onSms, + port: SMPP_PORT, + }); + +@@ -126,38 +127,43 @@ function answered(session: Session, arrivalIndex: number, result: { err?: Error + if (result.err) s.unansweredErrors++; + } + +-function slowRespond(session: Session, sms: Sms, arrivalIndex: number): void { ++function slowRespond(session: Session, sms: Sms, arrivalIndex: number): Promise { + const chain = (slowQueues.get(session) ?? Promise.resolve()) + .then(async () => { await delay(SLOW_HANDLER_DELAY_MS); }) + .then(async () => { answered(session, arrivalIndex, await sms.sendResp()); }); + + slowQueues.set(session, chain); ++ ++ return chain; + } + +-function fastRespond(session: Session, sms: Sms, arrivalIndex: number): void { +- void sms.sendResp().then(result => { answered(session, arrivalIndex, result); }); ++function fastRespond(session: Session, sms: Sms, arrivalIndex: number): Promise { ++ return sms.sendResp().then(result => { answered(session, arrivalIndex, result); }); + } + +-smpp.on('session', session => { +- // Attached now, not lazily in a test body - see the comment on ScenarioStats.closed. +- session.on('close', () => { statsFor(scenarioOf(session)).closed = true; }); ++/** The handler stays in each message until it is answered, so the throttle bound counts what is unanswered. */ ++function onSms(sms: Sms): Promise { ++ const { session } = sms; ++ const name = scenarioOf(session); + +- session.on('sms', sms => { +- const name = scenarioOf(session); ++ sessionByScenario.set(name, session); + +- sessionByScenario.set(name, session); ++ const s = statsFor(name); ++ const arrivalIndex = s.arrived; + +- const s = statsFor(name); +- const arrivalIndex = s.arrived; ++ s.arrived++; ++ if (s.ids.has(sms.smsId)) s.duplicateIds++; ++ else s.ids.add(sms.smsId); ++ s.peakOutstanding = Math.max(s.peakOutstanding, s.arrived - s.answered); + +- s.arrived++; +- if (s.ids.has(sms.smsId)) s.duplicateIds++; +- else s.ids.add(sms.smsId); +- s.peakOutstanding = Math.max(s.peakOutstanding, s.arrived - s.answered); ++ if (name === 'dumb-w500' || name === 'dumb-w2000') return slowRespond(session, sms, arrivalIndex); + +- if (name === 'dumb-w500' || name === 'dumb-w2000') slowRespond(session, sms, arrivalIndex); +- else fastRespond(session, sms, arrivalIndex); +- }); ++ return fastRespond(session, sms, arrivalIndex); ++} ++ ++smpp.on('session', session => { ++ // Attached now, not lazily in a test body - see the comment on ScenarioStats.closed. ++ session.on('close', () => { statsFor(scenarioOf(session)).closed = true; }); + }); + + function memShape(): string { +@@ -206,7 +212,7 @@ after(async () => { + + // S9 (target 11) and the backpressure-at-server scenario: window 2000 at a high rate against a + // handler slowed enough to build a real backlog. window500 is the same shape with a window below +-// maxHeldMessages (1000, session-options.ts defaults.maxHeldMessages), the bound past which a ++// maxHandledMessages (1000, defaults.ts), the bound past which a + // peer's window is answered ESME_RTHROTTLED. smpp-dumb-client counts a throttled message as sent + // and never resends it, so window 2000 accounts for 20,000 as answered plus throttled. + const throttleMessage = 'session - unanswered messages at their bound, asking the peer to retry'; +@@ -236,7 +242,7 @@ describe('S9 - bounded window against a slowed handler', () => { + }); + } + +- test('window 2000 pressed past maxHeldMessages (1000): the peer is throttled, window500 never is', () => { ++ test('window 2000 pressed past maxHandledMessages (1000): the peer is throttled, window500 never is', () => { + assert.ok(throttled('dumb-w2000') > 0, 'expected at least one ESME_RTHROTTLED under window 2000'); + assert.ok(statsFor('dumb-w2000').peakOutstanding <= 1000); + assert.equal(statsFor('dumb-w500').peakOutstanding <= 500, true); +diff --git a/interop-tests/jasmin.test.ts b/interop-tests/jasmin.test.ts +index c6c9b92..d6ef5f5 100644 +--- a/interop-tests/jasmin.test.ts ++++ b/interop-tests/jasmin.test.ts +@@ -86,6 +86,18 @@ const { err: upstreamErr, server: upstream } = await server({ + // was already in flight for Jasmin's own requeue_delay (120s default) before it retries - far past + // any per-test wait budget here - so this is generous specifically to never be the trigger. + idleTimeout: 300_000, ++ onSms: async sms => { ++ const variant = (sms.session.userData as { variant?: UpstreamVariant } | undefined)?.variant; ++ ++ if (variant) upstreamSms.push({ sms, variant }); ++ ++ await sms.sendResp(); ++ ++ if (sms.dlr) { ++ await delay(150); ++ await sms.sendDlr('DELIVERED'); ++ } ++ }, + port: UPSTREAM_PORT, + }); + +@@ -105,21 +117,6 @@ upstreamServer.on('session', session => { + + if (variant) upstreamSessions.set(variant, session); + }); +- +- session.on('sms', sms => { +- const variant = (session.userData as { variant?: UpstreamVariant } | undefined)?.variant; +- +- if (variant) upstreamSms.push({ sms, variant }); +- +- void (async () => { +- await sms.sendResp(); +- +- if (sms.dlr) { +- await delay(150); +- await sms.sendDlr('DELIVERED'); +- } +- })(); +- }); + }); + + async function waitForUpstreamSession(variant: UpstreamVariant, budget = 20_000): Promise { +@@ -227,7 +224,7 @@ async function sendUdhMo(session: Session, opts: { from: string; message: string + const multipart = segments.length > 1; + + for (const segment of segments) { +- const params = submitSmParams({ from: opts.from, message: opts.message, to: opts.to }, segment, { encoding: 'ASCII', multipart }); ++ const params = submitSmParams({ from: opts.from, message: opts.message, to: opts.to }, segment, { encoding: 'GSM7', multipart }); + const sent = await session.send({ cmdName: 'deliver_sm', params }); + + assert.equal(sent.err, undefined); +@@ -247,7 +244,7 @@ async function sendMessagePayloadMo(session: Session, opts: { from: string; mess + }, + tlvs: { + // The body is octets under the PDU's own data_coding wherever it is carried, and 0 is GSM. +- message_payload: { tagValue: encodeMessage(opts.message, 'ASCII').buffer }, ++ message_payload: { tagValue: encodeMessage(opts.message, 'GSM7').buffer }, + }, + }); + } +@@ -397,15 +394,12 @@ describe('S7 - Jasmin as the ESME against our server (HTTP send API, DLR callbac + + test('a long GSM message from our server reassembles at Jasmin (or is recorded as fragments)', async () => { + const upstreamSession = await waitForUpstreamSession('main'); +- const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' }); ++ const sms: Sms[] = []; ++ const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver', onSms: s => { sms.push(s); } }); + + assert.equal(err, undefined); + assert.ok(session); + +- const sms: Sms[] = []; +- +- session.on('sms', s => { sms.push(s); }); +- + const text = `s7-long-${'p'.repeat(300)}`; + + await sendUdhMo(upstreamSession, { from: TO, message: text, to: FROM }); +@@ -418,15 +412,12 @@ describe('S7 - Jasmin as the ESME against our server (HTTP send API, DLR callbac + + test('a UCS-2 message with 一 and an emoji from our server (or is recorded as fragments)', async () => { + const upstreamSession = await waitForUpstreamSession('main'); +- const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' }); ++ const sms: Sms[] = []; ++ const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver', onSms: s => { sms.push(s); } }); + + assert.equal(err, undefined); + assert.ok(session); + +- const sms: Sms[] = []; +- +- session.on('sms', s => { sms.push(s); }); +- + const text = `一😀${'q'.repeat(60)}`; + + await sendSarMo(upstreamSession, { from: TO, message: text, to: FROM }); +@@ -502,15 +493,12 @@ describe('C3+C7 - long MT through the fake upstream, receipts and id consistency + describe('C8 (target 3) - long MO from an upstream SMSC, SAR vs UDH segmentation', () => { + test('SAR-segmented deliver_sm from the fake upstream', async () => { + const upstreamSession = await waitForUpstreamSession('main'); +- const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' }); ++ const sms: Sms[] = []; ++ const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver', onSms: s => { sms.push(s); } }); + + assert.equal(err, undefined); + assert.ok(session); + +- const sms: Sms[] = []; +- +- session.on('sms', s => { sms.push(s); }); +- + const text = `sar-mo-${'m'.repeat(300)}`; + + await sendSarMo(upstreamSession, { from: TO, message: text, to: FROM }); +@@ -526,15 +514,12 @@ describe('C8 (target 3) - long MO from an upstream SMSC, SAR vs UDH segmentation + + test('UDH-segmented deliver_sm from the fake upstream', async () => { + const upstreamSession = await waitForUpstreamSession('main'); +- const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' }); ++ const sms: Sms[] = []; ++ const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver', onSms: s => { sms.push(s); } }); + + assert.equal(err, undefined); + assert.ok(session); + +- const sms: Sms[] = []; +- +- session.on('sms', s => { sms.push(s); }); +- + const text = `udh-mo-${'n'.repeat(300)}`; + + await sendUdhMo(upstreamSession, { from: TO, message: text, to: FROM }); +@@ -551,15 +536,12 @@ describe('C8 (target 3) - long MO from an upstream SMSC, SAR vs UDH segmentation + describe('C8 (target 2) - message_payload with sm_length 0', () => { + test('a deliver_sm carrying message_payload instead of short_message', async () => { + const upstreamSession = await waitForUpstreamSession('main'); +- const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' }); ++ const sms: Sms[] = []; ++ const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver', onSms: s => { sms.push(s); } }); + + assert.equal(err, undefined); + assert.ok(session); + +- const sms: Sms[] = []; +- +- session.on('sms', s => { sms.push(s); }); +- + const text = 'message-payload only, sm_length 0'; + const pushed = await sendMessagePayloadMo(upstreamSession, { from: TO, message: text, to: FROM }); + +diff --git a/interop-tests/jsmpp.test.ts b/interop-tests/jsmpp.test.ts +index db96513..32e1b69 100644 +--- a/interop-tests/jsmpp.test.ts ++++ b/interop-tests/jsmpp.test.ts +@@ -43,9 +43,26 @@ const bindPdus: Record[] = []; + * before triggering the submit that will carry this exact text, so the global auto-ack never runs. */ + const manualTexts = new Set(); + ++/** Stays in the handler until the test has answered the message, so the answer is the test's own. */ ++function untilAnswered(sms: Sms): Promise { ++ return new Promise(resolve => { ++ const timer = setInterval(() => { ++ if (!sms.answered) return; ++ ++ clearInterval(timer); ++ resolve(); ++ }, 20); ++ }); ++} ++ + const { err: serverErr, server: smpp } = await server({ + authenticate: () => true, + idleTimeout: 40_000, ++ onSms: sms => { ++ allSms.push({ session: sms.session, sms }); ++ ++ return manualTexts.has(sms.message) ? untilAnswered(sms) : undefined; ++ }, + port: SMPP_PORT, + }); + +@@ -58,10 +75,6 @@ smppServer.on('session', session => { + session.on('incomingPduObj', pduObj => { + if (pduObj.cmdName.startsWith('bind_')) bindPdus.push(pduObj.params); + }); +- session.on('sms', sms => { +- allSms.push({ session, sms }); +- if (!manualTexts.has(sms.message)) void sms.sendResp(); +- }); + session.on('sessionError', err => { allSessionErrors.push({ err, session }); }); + }); + +@@ -135,7 +148,7 @@ describe('S2 - long messages in every spelling (targets 2, 3, 5)', () => { + + const sms = await waitForSms(text, 15_000); + +- assert.equal(sms.answeredOnArrival, true); ++ assert.equal(sms.answered, true); + assert.equal(segments[0]?.messageId, `${sms.smsId}-1`); + assert.equal(segments[1]?.messageId, `${sms.smsId}-2`); + }); +@@ -151,7 +164,7 @@ describe('S2 - long messages in every spelling (targets 2, 3, 5)', () => { + + const sms = await waitForSms(text, 15_000); + +- assert.equal(sms.answeredOnArrival, true); ++ assert.equal(sms.answered, true); + assert.equal(segments[0]?.messageId, `${sms.smsId}-1`); + assert.equal(segments[1]?.messageId, `${sms.smsId}-2`); + }); +@@ -170,7 +183,7 @@ describe('S2 - long messages in every spelling (targets 2, 3, 5)', () => { + const sms = await waitForSms(text); + + assert.equal(sms.message, text); +- assert.equal(sms.answeredOnArrival, false); ++ assert.equal(sms.answered, false); + }); + + test('sar_* (target 3): one reassembled sms, each segment answered -', async () => { +@@ -186,7 +199,7 @@ describe('S2 - long messages in every spelling (targets 2, 3, 5)', () => { + + const sms = await waitForSms(text, 15_000); + +- assert.equal(sms.answeredOnArrival, true); ++ assert.equal(sms.answered, true); + assert.equal(segments[0]?.messageId, `${sms.smsId}-1`); + assert.equal(segments[1]?.messageId, `${sms.smsId}-2`); + // Neither ~130-char slice ever reached the application on its own. +diff --git a/interop-tests/kannel.test.ts b/interop-tests/kannel.test.ts +index 0d911d0..5c6a3d4 100644 +--- a/interop-tests/kannel.test.ts ++++ b/interop-tests/kannel.test.ts +@@ -142,6 +142,21 @@ function variantFromSystemId(systemId: string): Variant | undefined { + } + + const allSms: { sms: Sms; variant: Variant }[] = []; ++ ++/** Stays in the handler until the test has answered the message, so the answer is the test's own. */ ++function untilAnswered(sms: Sms): Promise { ++ return new Promise(resolve => { ++ const timer = setInterval(() => { ++ if (!sms.answered) return; ++ ++ clearInterval(timer); ++ resolve(); ++ }, 20); ++ }); ++} ++ ++/** Sessions whose messages are answered as they land, for a peer that sends one at a time. */ ++const answeredOnArrival = new Set(); + const allDlrs: { dlr: Dlr; variant: Variant }[] = []; + const bindPdus: { params: Record; variant: Variant }[] = []; + +@@ -154,6 +169,13 @@ const { err: serverErr, server: smpp } = await server({ + return variant ? { userData: { variant } } : false; + }, + idleTimeout: 40_000, ++ onSms: sms => { ++ const variant = (sms.session.userData as { variant?: Variant } | undefined)?.variant; ++ ++ if (variant) allSms.push({ sms, variant }); ++ ++ return answeredOnArrival.has(sms.session) ? undefined : untilAnswered(sms); ++ }, + port: SMPP_PORT, + }); + +@@ -171,12 +193,6 @@ smppServer.on('session', session => { + if (variant) bindPdus.push({ params: pduObj.params, variant }); + }); + +- session.on('sms', sms => { +- const variant = (session.userData as { variant?: Variant } | undefined)?.variant; +- +- if (variant) allSms.push({ sms, variant }); +- }); +- + session.on('dlr', dlr => { + const variant = (session.userData as { variant?: Variant } | undefined)?.variant; + +@@ -519,7 +535,7 @@ describe('maxp1 variant - max-pending-submits 1', () => { + + // max-pending-submits=1 means bearerbox holds the link to one outstanding submit_sm at a + // time - answer each as it lands, or the whole burst stalls behind the first message. +- session.on('sms', sms => { void sms.sendResp(); }); ++ answeredOnArrival.add(session); + + const texts = Array.from({ length: 20 }, (_, i) => `burst-${String(i).padStart(2, '0')}`); + +diff --git a/interop-tests/php.test.ts b/interop-tests/php.test.ts +index 0415ce9..217d2a6 100644 +--- a/interop-tests/php.test.ts ++++ b/interop-tests/php.test.ts +@@ -48,6 +48,14 @@ const { err: serverErr, server: smpp } = await server({ + + return true; + }, ++ // php-smpp's submit_sm() blocks synchronously reading the response on the same connection that ++ // sent it, so the message is answered as the handler returns, before any test observes it - ++ // unlike python-smpplib's driver, this one has no separate reader thread to poll afterwards. ++ onSms: sms => { ++ const systemId = systemIdBySession.get(sms.session); ++ ++ if (systemId) allSms.push({ sms, systemId }); ++ }, + port: SMPP_PORT, + }); + +@@ -62,18 +70,6 @@ smppServer.on('session', session => { + + systemIdBySession.set(session, paramText(pduObj.params.system_id)); + }); +- +- session.on('sms', sms => { +- const systemId = systemIdBySession.get(session); +- +- if (systemId) allSms.push({ sms, systemId }); +- +- // php-smpp's submit_sm() blocks synchronously reading the response on the same connection +- // that sent it, so answering here (rather than after this event's own test observes the +- // sms) is the only way that read ever completes - unlike python-smpplib's driver, this one +- // has no separate reader thread to poll afterwards. +- void sms.sendResp(); +- }); + }); + + after(async () => { +diff --git a/interop-tests/python.test.ts b/interop-tests/python.test.ts +index 84f1c66..071a4b5 100644 +--- a/interop-tests/python.test.ts ++++ b/interop-tests/python.test.ts +@@ -58,6 +58,18 @@ async function bindReader(name: string, opts: Partial = {}): Promise { ++ return new Promise(resolve => { ++ const timer = setInterval(() => { ++ if (!sms.answered) return; ++ ++ clearInterval(timer); ++ resolve(); ++ }, 20); ++ }); ++} + const systemIdBySession = new Map(); + + const { err: serverErr, server: smpp } = await server({ +@@ -68,6 +80,13 @@ const { err: serverErr, server: smpp } = await server({ + + return true; + }, ++ onSms: sms => { ++ const systemId = systemIdBySession.get(sms.session); ++ ++ if (systemId) allSms.push({ sms, systemId }); ++ ++ return untilAnswered(sms); ++ }, + port: SMPP_PORT, + }); + +@@ -82,12 +101,6 @@ smppServer.on('session', session => { + + systemIdBySession.set(session, paramText(pduObj.params.system_id)); + }); +- +- session.on('sms', sms => { +- const systemId = systemIdBySession.get(session); +- +- if (systemId) allSms.push({ sms, systemId }); +- }); + }); + + after(async () => { +@@ -149,7 +162,7 @@ async function waitForAck(name: string, sequence: number, budget = 8000): Promis + } + + async function echoBack(session: Session, sms: Sms, dataCoding: number, text: string): Promise { +- const encName = dataCoding === 3 ? 'LATIN1' : dataCoding === 8 ? 'UCS2' : 'ASCII'; ++ const encName = dataCoding === 3 ? 'LATIN1' : dataCoding === 8 ? 'UCS2' : 'GSM7'; + const buf = encodings[encName].encode(text); + const sent = await session.send({ + cmdName: 'deliver_sm', +@@ -320,7 +333,7 @@ describe('S2 - long messages (python-smpplib, UDH)', () => { + + const sms = await waitForSms(name, text, 15_000); + +- assert.equal(sms.answeredOnArrival, true); ++ assert.equal(sms.answered, true); + + const results = sent.results as { messageId: string; status: number }[]; + +diff --git a/interop-tests/smppsim.test.ts b/interop-tests/smppsim.test.ts +index 8a08817..c434c0f 100644 +--- a/interop-tests/smppsim.test.ts ++++ b/interop-tests/smppsim.test.ts +@@ -72,14 +72,6 @@ function collectDlrs(session: Session): Received[] { + return received; + } + +-function collectSms(session: Session): Sms[] { +- const collected: Sms[] = []; +- +- session.on('sms', sms => { collected.push(sms); }); +- +- return collected; +-} +- + const DLR_RETRY_BUDGET_MS = 3000; + const DLR_MAX_ATTEMPTS = 10; + +@@ -202,14 +194,14 @@ describe('smppsim - C3+C7 long MT, receipts and loopback reassembly', () => { + + for (const testCase of cases) { + test(testCase.label, async t => { +- const { err, session } = await bind(PEER_HOST); ++ const sms: Sms[] = []; ++ const { err, session } = await bind(PEER_HOST, { onSms: s => { sms.push(s); } }); + + assert.equal(err, undefined); + assert.ok(session); + closeAfter(t, session); + + const dlrs = collectDlrs(session); +- const sms = collectSms(session); + + const { reassembled, smsIds } = await sendUntilComplete( + session, +@@ -588,13 +580,13 @@ describe('smppsim - C15 bind version negotiation', () => { + + describe('smppsim - C17 encodings round trip over loopback', () => { + test('Latin-1 (å ä ö)', async t => { +- const { err, session } = await bind(PEER_HOST); ++ const sms: Sms[] = []; ++ const { err, session } = await bind(PEER_HOST, { onSms: s => { sms.push(s); } }); + + assert.equal(err, undefined); + assert.ok(session); + closeAfter(t, session); + +- const sms = collectSms(session); + + await session.sendSms({ encoding: 'LATIN1', from: FROM, message: 'å ä ö', to: TO }); + +@@ -605,13 +597,13 @@ describe('smppsim - C17 encodings round trip over loopback', () => { + }); + + test('UCS-2', async t => { +- const { err, session } = await bind(PEER_HOST); ++ const sms: Sms[] = []; ++ const { err, session } = await bind(PEER_HOST, { onSms: s => { sms.push(s); } }); + + assert.equal(err, undefined); + assert.ok(session); + closeAfter(t, session); + +- const sms = collectSms(session); + + await session.sendSms({ encoding: 'UCS2', from: FROM, message: 'ucs2 round trip', to: TO }); + +@@ -622,13 +614,13 @@ describe('smppsim - C17 encodings round trip over loopback', () => { + }); + + test('flash (data_coding records the message-class group)', async t => { +- const { err, session } = await bind(PEER_HOST); ++ const sms: Sms[] = []; ++ const { err, session } = await bind(PEER_HOST, { onSms: s => { sms.push(s); } }); + + assert.equal(err, undefined); + assert.ok(session); + closeAfter(t, session); + +- const sms = collectSms(session); + + await session.sendSms({ flash: true, from: FROM, message: 'flash test', to: TO }); + +@@ -640,13 +632,13 @@ describe('smppsim - C17 encodings round trip over loopback', () => { + }); + + test('a raw submit_sm with data_coding 0xF0 is read as flash', async t => { +- const { err, session } = await bind(PEER_HOST); ++ const sms: Sms[] = []; ++ const { err, session } = await bind(PEER_HOST, { onSms: s => { sms.push(s); } }); + + assert.equal(err, undefined); + assert.ok(session); + closeAfter(t, session); + +- const sms = collectSms(session); + const body = 'message class test'; + + const sent = await session.send({ +@@ -668,13 +660,13 @@ describe('smppsim - C17 encodings round trip over loopback', () => { + }); + + test('a raw submit_sm with 8-bit binary and a UDH (esm_class 0x40)', async t => { +- const { err, session } = await bind(PEER_HOST); ++ const sms: Sms[] = []; ++ const { err, session } = await bind(PEER_HOST, { onSms: s => { sms.push(s); } }); + + assert.equal(err, undefined); + assert.ok(session); + closeAfter(t, session); + +- const sms = collectSms(session); + // A UDH carrying no recognised concatenation IE (0x00/0x08): one element in GSM 03.40's + // reserved-for-future-use range (0x70), so Wireshark's gsm_sms_ud dissector - which + // validates the *typed* IEs' own lengths (0x01 "Special SMS Message Indication" must be +diff --git a/interop-tests/smscsim.test.ts b/interop-tests/smscsim.test.ts +index ba23042..0c1785c 100644 +--- a/interop-tests/smscsim.test.ts ++++ b/interop-tests/smscsim.test.ts +@@ -163,9 +163,11 @@ describe('smscsim - multipart segments', () => { + + describe('smscsim - MO injection through the web UI', () => { + test('a message posted to the web page arrives as an sms event', async t => { ++ const incoming: Sms[] = []; + const { err, session } = await client({ + bindType: 'transceiver', + host: PEER_HOST, ++ onSms: sms => { incoming.push(sms); }, + port: PEER_PORT, + username: 'mo-inject', + }); +@@ -174,10 +176,6 @@ describe('smscsim - MO injection through the web UI', () => { + assert.ok(session); + closeAfter(t, session); + +- const incoming: Sms[] = []; +- +- session.on('sms', sms => { incoming.push(sms); }); +- + const response = await fetch(`http://${PEER_HOST}:${String(PEER_WEB_PORT)}/`, { + body: new URLSearchParams({ + message: 'hello from the web UI', +diff --git a/src/backoff.ts b/src/backoff.ts +new file mode 100644 +index 0000000..e60e634 +--- /dev/null ++++ b/src/backoff.ts +@@ -0,0 +1,44 @@ ++import { defaults } from './defaults.ts'; ++ ++export type BackoffOptions = { ++ maxDelay?: number | undefined; ++ minDelay?: number | undefined; ++ now?: (() => number) | undefined; ++}; ++ ++/** The wait before each connect attempt: doubling from minDelay to maxDelay, and over again once a link has lasted. */ ++export class Backoff { ++ private readonly maxDelay: number; ++ private readonly minDelay: number; ++ private readonly now: () => number; ++ private delay: number; ++ private upAt: number | undefined; ++ ++ constructor(options: BackoffOptions = {}) { ++ this.maxDelay = options.maxDelay ?? defaults.reconnect.maxDelay; ++ this.minDelay = options.minDelay ?? defaults.reconnect.minDelay; ++ this.now = options.now ?? Date.now; ++ this.delay = this.minDelay; ++ } ++ ++ /** The wait before the next attempt. */ ++ next(): number { ++ // Coming up is not proof: a stream we cannot read is only found once the link is bound. ++ if (this.upAt !== undefined && this.now() - this.upAt >= this.maxDelay) { ++ this.delay = this.minDelay; ++ } ++ ++ this.upAt = undefined; ++ ++ const delay = this.delay; ++ ++ this.delay = Math.min(delay * 2, this.maxDelay); ++ ++ return delay; ++ } ++ ++ /** A link is up: whether it lasts decides where the next wait starts. */ ++ linkUp(): void { ++ this.upAt = this.now(); ++ } ++} +diff --git a/src/client.ts b/src/client.ts +index 6b7299c..d757bfe 100644 +--- a/src/client.ts ++++ b/src/client.ts +@@ -1,17 +1,17 @@ + import type { ConnectionOptions } from 'node:tls'; + import type { Result, VoidResult } from './result.ts'; +-import type { BindType, ReconnectOptions } from './session-options.ts'; ++import type { BindType, OnSms, ReconnectOptions } from './session-options.ts'; + import type { SmppLog } from './log.ts'; + import type { SmsIdFormat } from './sms-id.ts'; + import type { Socket } from 'node:net'; + export type { BindType }; + +-import { ReconnectLoop } from './reconnect-loop.ts'; ++import { Backoff } from './backoff.ts'; + import { Session } from './session.ts'; + import { checkSessionOptions } from './session-options.ts'; + import { connect as netConnect } from 'node:net'; + import { connect as tlsConnect } from 'node:tls'; +-import { defaultInterfaceVersion } from './defs/constants.ts'; ++import { defaults } from './defaults.ts'; + import { guardedLog } from './log.ts'; + + /** `fromStart` puts the very first connect and bind through the same backoff loop as a drop. */ +@@ -29,6 +29,7 @@ export type ClientOptions = { + interfaceVersion?: number; + log?: SmppLog; + maxOutstanding?: number; ++ onSms?: OnSms; + password?: string; + port?: number; + reconnect?: ReconnectTuning | false; +@@ -41,19 +42,6 @@ export type ClientOptions = { + username?: string; + }; + +-const defaults = { +- bindType: 'transceiver', +- connectTimeout: 10_000, +- enquireLinkInterval: 20_000, +- host: 'localhost', +- /** The idle timeout is what notices a dead link, so it has to outlast one silent probe. */ +- idleTimeoutFactor: 2, +- interfaceVersion: defaultInterfaceVersion, +- password: 'pass', +- port: 2775, +- username: 'user', +-} as const; +- + function armConnectTimeout( + sock: Socket, + connectTimeout: number | false, +@@ -77,9 +65,9 @@ function armConnectTimeout( + } + + function openSocket(options: ClientOptions): Promise> { +- const connectTimeout = options.connectTimeout ?? defaults.connectTimeout; +- const host = options.host ?? defaults.host; +- const port = options.port ?? defaults.port; ++ const connectTimeout = options.connectTimeout ?? defaults.client.connectTimeout; ++ const host = options.host ?? defaults.client.host; ++ const port = options.port ?? defaults.client.port; + const secure = options.tls !== undefined && options.tls !== false; + const tlsOptions = typeof options.tls === 'object' ? options.tls : undefined; + +@@ -134,9 +122,9 @@ async function connectSocket(options: ClientOptions, log: SmppLog): Promise { +- const bindType = options.bindType ?? defaults.bindType; +- const systemId = options.username ?? defaults.username; ++ const bindType = options.bindType ?? defaults.client.bindType; ++ const systemId = options.username ?? defaults.client.username; + const sent = await session.send( + { cmdName: `bind_${bindType}`, params: bindParams(options, systemId) }, + options.signal ? { signal: options.signal } : {}, +@@ -197,13 +185,14 @@ function reconnectFor(options: ClientOptions, log: SmppLog): ReconnectOptions | + } + + function createSession(options: ClientOptions, log: SmppLog, sock: Socket): Session { +- const enquireLinkInterval = options.enquireLinkInterval ?? defaults.enquireLinkInterval; ++ const enquireLinkInterval = options.enquireLinkInterval ?? defaults.client.enquireLinkInterval; + + return new Session({ + enquireLinkInterval, +- idleTimeout: options.idleTimeout ?? enquireLinkInterval * defaults.idleTimeoutFactor, ++ idleTimeout: options.idleTimeout ?? enquireLinkInterval * defaults.client.idleTimeoutFactor, + log, + maxOutstanding: options.maxOutstanding, ++ onSms: options.onSms, + reconnect: reconnectFor(options, log), + responseTimeout: options.responseTimeout, + shutdownTimeout: options.shutdownTimeout, +@@ -212,6 +201,7 @@ function createSession(options: ClientOptions, log: SmppLog, sock: Socket): Sess + }); + } + ++/** One connect and bind. A failure takes the session down, since the caller never sees it. */ + async function connectAndBind( + options: ClientOptions, + log: SmppLog, +@@ -220,22 +210,8 @@ async function connectAndBind( + + if (opened.err) return { err: opened.err }; + +- return bindOn(createSession(options, log, opened.sock), options); +-} +- +-/** Binds a session the caller has not seen yet, so a failure takes it down instead of surfacing. */ +-async function bindOn( +- session: Session, +- options: ClientOptions, +-): Promise> { ++ const session = createSession(options, log, opened.sock); + const signal = options.signal; +- +- if (signal?.aborted === true) { +- void session.close({ signal }); +- +- return { err: new Error('Aborted before binding') }; +- } +- + const onAbort = (): void => { void session.close({ signal }); }; + + // Registered before the bind: an abort landing while it is in flight has to close the session. +@@ -243,45 +219,62 @@ async function bindOn( + + const bound = await bind(session, options); + +- if (bound.err) { +- signal?.removeEventListener('abort', onAbort); +- // close() must reach the loop's stop() before its first await, or this session retries too. +- void session.close({ signal }); ++ if (!bound.err) return { session }; + +- return { err: bound.err }; +- } ++ signal?.removeEventListener('abort', onAbort); ++ void session.close({ signal }); + +- return { session }; ++ return { err: bound.err }; + } + + function retriesFromStart(reconnect: ClientOptions['reconnect']): reconnect is ReconnectTuning { + return reconnect !== undefined && reconnect !== false && reconnect.fromStart === true; + } + +-/** A fresh session per attempt, and the failure to answer an abort with when none of them binds. */ +-function initialAttempts(options: ClientOptions, log: SmppLog, failed: Error) { +- let lastErr = failed; ++type Settle = (result: Result<{ session: Session }>) => void; + +- return { +- bind: async (sock: Socket): Promise> => { +- const bound = await bindOn(createSession(options, log, sock), options); ++/** Whether the caller is still waiting: a session bound after it stopped is closed rather than leaked. */ ++type Waiting = () => boolean; + +- if (bound.err) lastErr = bound.err; ++/** Tries again after each failure, on the backoff a drop takes, until one attempt binds. */ ++function retryUntilBound(options: ClientOptions, log: SmppLog, backoff: Backoff, waiting: Waiting, settle: Settle): () => void { ++ let timer: NodeJS.Timeout | undefined; + +- return bound; +- }, +- connect: async (): Promise> => { +- const opened = await connectSocket(options, log); ++ async function attempt(): Promise { ++ const bound = await connectAndBind(options, log); + +- if (opened.err) lastErr = opened.err; ++ if (!waiting()) { ++ await bound.session?.close({ signal: AbortSignal.abort() }); + +- return opened; +- }, +- lastErr: (): Error => lastErr, +- }; ++ return; ++ } ++ ++ if (!bound.err) { ++ settle({ session: bound.session }); ++ ++ return; ++ } ++ ++ settle({ err: bound.err }); ++ schedule(); ++ } ++ ++ function schedule(): void { ++ const delay = backoff.next(); ++ ++ // Awaited with no other handle, so an unref()'d wait would exit the process unbound. ++ timer = setTimeout(() => { ++ log.info('reconnect - retrying', { delay }); ++ void attempt(); ++ }, delay); ++ } ++ ++ schedule(); ++ ++ return () => { clearTimeout(timer); }; + } + +-/** Retries the first connect and bind, on the backoff a drop takes, until one of them binds. */ ++/** Retries the first connect and bind until one of them binds; only the caller's signal ends the wait. */ + function keepTrying( + options: ClientOptions, + log: SmppLog, +@@ -289,42 +282,29 @@ function keepTrying( + failed: Error, + ): Promise> { + return new Promise(resolve => { +- const attempts = initialAttempts(options, log, failed); + const signal = options.signal; ++ let lastErr = failed; + let settled = false; +- const loop = new ReconnectLoop({ +- connect: attempts.connect, +- log, +- maxDelay: tuning.maxDelay, +- minDelay: tuning.minDelay, +- onConnected: async sock => { +- const bound = await attempts.bind(sock); +- +- if (bound.err) return { err: bound.err }; +- +- settle({ session: bound.session }); +- +- return {}; +- }, +- // Awaited with no other handle, so an unref()'d wait would exit the process unbound. +- unref: false, +- }); ++ const stop = retryUntilBound(options, log, new Backoff(tuning), () => !settled, result => { ++ // A failed attempt only records what to blame an abort on; a bound one is the answer. ++ if (result.err) { ++ lastErr = result.err; + +- function settle(result: Result<{ session: Session }>): void { +- if (settled) return; ++ return; ++ } + + settled = true; +- loop.stop(); + signal?.removeEventListener('abort', onAbort); + resolve(result); +- } ++ }); + + function onAbort(): void { +- settle({ err: new Error('Aborted while connecting', { cause: attempts.lastErr() }) }); ++ settled = true; ++ stop(); ++ resolve({ err: new Error('Aborted while connecting', { cause: lastErr }) }); + } + + signal?.addEventListener('abort', onAbort, { once: true }); +- loop.schedule(); + }); + } + +diff --git a/src/defaults.ts b/src/defaults.ts +new file mode 100644 +index 0000000..8c1eb72 +--- /dev/null ++++ b/src/defaults.ts +@@ -0,0 +1,41 @@ ++import { defaultInterfaceVersion } from './defs/constants.ts'; ++ ++/** Every default this library runs on. README documents each beside the option it fills. */ ++export const defaults = { ++ client: { ++ bindType: 'transceiver', ++ connectTimeout: 10_000, ++ enquireLinkInterval: 20_000, ++ host: 'localhost', ++ /** The idle timeout is what notices a dead link, so it has to outlast one silent probe. */ ++ idleTimeoutFactor: 2, ++ password: 'pass', ++ port: 2775, ++ username: 'user', ++ }, ++ reconnect: { ++ maxDelay: 30_000, ++ minDelay: 1000, ++ }, ++ server: { ++ idleTimeout: 40_000, ++ port: 2775, ++ }, ++ session: { ++ /** Receipts of a multipart message can be a working day apart, so the cap does the bounding. */ ++ dlrMergeTimeout: 86_400_000, ++ /** The peer gave up on an unanswered message long before this; the bound is against growth. */ ++ handledMessageTimeout: 300_000, ++ interfaceVersion: defaultInterfaceVersion, ++ maxDlrMerges: 1000, ++ maxHandledMessages: 1000, ++ maxHandledOctets: 64 * 1024 * 1024, ++ maxOutstanding: 10, ++ maxReassembly: 1000, ++ maxReassemblyOctets: 64 * 1024 * 1024, ++ reassemblyTimeout: 300_000, ++ responseTimeout: 30_000, ++ shutdownTimeout: 5000, ++ systemId: '', ++ }, ++} as const; +diff --git a/src/defs/encodings.ts b/src/defs/encodings.ts +index c454b02..98ebfe4 100644 +--- a/src/defs/encodings.ts ++++ b/src/defs/encodings.ts +@@ -1,4 +1,4 @@ +-export type EncodingName = 'ASCII' | 'LATIN1' | 'UCS2'; ++export type EncodingName = 'GSM7' | 'LATIN1' | 'UCS2'; + + export type Encoding = { + decode: (buffer: Uint8Array) => string; +@@ -42,7 +42,8 @@ for (const [extended, base] of gsmExtendedPairs) { + gsmExtChars.set(base, extended); + } + +-const ascii: Encoding = { ++/** GSM 03.38 7-bit, one character per octet: the SMSC packs the septets, not the ESME. */ ++const gsm7: Encoding = { + decode(buffer) { + let result = ''; + +@@ -103,7 +104,7 @@ const ucs2: Encoding = { + }; + + export const encodings: Record = { +- ASCII: ascii, ++ GSM7: gsm7, + LATIN1: latin1, + UCS2: ucs2, + }; +@@ -115,7 +116,7 @@ export function isEncodingName(value: unknown): value is EncodingName { + } + + export function detect(value: string): EncodingName { +- if (encodings.ASCII.match(value)) return 'ASCII'; ++ if (encodings.GSM7.match(value)) return 'GSM7'; + if (encodings.LATIN1.match(value)) return 'LATIN1'; + + return 'UCS2'; +@@ -163,21 +164,21 @@ function messageClassEncoding(dataCoding: number): EncodingName | undefined { + if (messageClassOf(dataCoding) === undefined) return undefined; + + if ((dataCoding & 0xF0) === 0xF0) { +- return (dataCoding & 0x04) === 0x04 ? 'LATIN1' : 'ASCII'; ++ return (dataCoding & 0x04) === 0x04 ? 'LATIN1' : 'GSM7'; + } + + const alphabet = (dataCoding >> 2) & 0x03; + + if (alphabet === 0x01) return 'LATIN1'; + +- return alphabet === 0x02 ? 'UCS2' : 'ASCII'; ++ return alphabet === 0x02 ? 'UCS2' : 'GSM7'; + } + + /** + * SMPP data_coding is a flat table for 0x00-0x0E, and the message class ranges are how a flash UCS2 + * message arrives as 0x18. The 8-bit binary codings resolve to LATIN1, the one codec here that maps + * every octet to a code point and back unchanged, so a binary payload survives; alphabets with no +- * codec fall back to ASCII. ++ * codec fall back to GSM7. + */ + export function encodingByDataCoding(dataCoding: number): EncodingName { + const messageClass = messageClassEncoding(dataCoding); +@@ -186,7 +187,7 @@ export function encodingByDataCoding(dataCoding: number): EncodingName { + if (dataCoding === 0x08) return 'UCS2'; + + // 0x02 and 0x04 are 8-bit binary, 0x03 is Latin-1. +- return dataCoding >= 0x02 && dataCoding <= 0x04 ? 'LATIN1' : 'ASCII'; ++ return dataCoding >= 0x02 && dataCoding <= 0x04 ? 'LATIN1' : 'GSM7'; + } + + /** +@@ -194,7 +195,7 @@ export function encodingByDataCoding(dataCoding: number): EncodingName { + * takes 0x00, the SMSC default alphabet, rather than SMPP 3.4 5.2.19's 0x01, which is IA5. + */ + export const dataCodingByEncoding: Readonly> = { +- ASCII: 0x00, ++ GSM7: 0x00, + LATIN1: 0x03, + UCS2: 0x08, + }; +diff --git a/src/dlr-merger.ts b/src/dlr-merger.ts +index 0c20cae..12a4349 100644 +--- a/src/dlr-merger.ts ++++ b/src/dlr-merger.ts +@@ -126,7 +126,7 @@ export class DlrMerger { + + if (group.parts.size < group.expected.size) return undefined; + +- this.close(base); ++ this.spend(base); + + const segments = [...group.parts.entries()].sort(([a], [b]) => a - b).map(([, one]) => one); + const worst = segments.reduce((carry, one) => (severity[one.statusMsg] > severity[carry.statusMsg] ? one : carry)); +@@ -142,7 +142,7 @@ export class DlrMerger { + /** Drops every group past its deadline. Runs before each collect and on its own timer. */ + sweep(): void { + for (const [base, group] of this.groups.takeExpired()) { +- this.close(base); ++ this.spend(base); + this.log.info('dlrMerger - incomplete receipts expired', { base, expected: group.expected.size }); + } + } +@@ -151,7 +151,7 @@ export class DlrMerger { + this.spent.takeExpired(); + + if (this.groups.get(base) !== undefined || this.spent.get(base) === true) { +- this.close(base); ++ this.spend(base); + this.log.info('dlrMerger - message id handed out again, leaving its receipts unmerged', { base }); + + return; +@@ -162,7 +162,8 @@ export class DlrMerger { + this.groups.set(base, { expected, parts: new Map() }); + } + +- private close(base: string): void { ++ /** The base has been merged, or given up on: its receipts are never merged again. */ ++ private spend(base: string): void { + this.groups.delete(base); + this.spent.delete(base); + +@@ -178,7 +179,7 @@ export class DlrMerger { + + const [base] = oldest; + +- this.close(base); ++ this.spend(base); + this.log.warn('dlrMerger - buffer full, dropping the oldest message', { base, max: this.max }); + } + } +diff --git a/src/handled-messages.ts b/src/handled-messages.ts +new file mode 100644 +index 0000000..69b4354 +--- /dev/null ++++ b/src/handled-messages.ts +@@ -0,0 +1,158 @@ ++import type { ErrorName } from './defs/errors.ts'; ++import type { OnSms } from './session-options.ts'; ++import type { Sms, SmsHandlers, SmsInput } from './sms.ts'; ++import type { SmppLog } from './log.ts'; ++import { ExpiringGroups } from './expiring-groups.ts'; ++import { IdleWaiters } from './idle-waiters.ts'; ++import { createSms } from './sms.ts'; ++import { errorFrom } from './error-from.ts'; ++import { retainedOctets } from './retained-pdu.ts'; ++ ++export type HandledMessagesOptions = { ++ log: SmppLog; ++ max: number; ++ maxOctets: number; ++ /** Injected so expiry can be exercised without a wall clock. */ ++ now?: (() => number) | undefined; ++ onSms: OnSms | undefined; ++ /** Where a handler's failure is reported. */ ++ report: (err: Error) => void; ++ timeout: number; ++}; ++ ++/** ++ * The messages whose `onSms` is running. Each counts toward the bound and holds a shutdown until ++ * the handler settles, its deadline passes, or the link goes; and each is answered once the handler ++ * has settled, where the handler did not answer it itself. ++ */ ++export class HandledMessages { ++ private readonly idleWaiters = new IdleWaiters(); ++ private readonly log: SmppLog; ++ private readonly max: number; ++ private readonly maxOctets: number; ++ private readonly onSms: OnSms | undefined; ++ private readonly report: (err: Error) => void; ++ private readonly running: ExpiringGroups; ++ private atBound = false; ++ private keys = 0; ++ ++ constructor(options: HandledMessagesOptions) { ++ this.log = options.log; ++ this.max = options.max; ++ this.maxOctets = options.maxOctets; ++ this.onSms = options.onSms; ++ this.report = options.report; ++ this.running = new ExpiringGroups({ ++ max: options.max, ++ now: options.now, ++ onSweep: () => { this.sweep(); }, ++ timeout: options.timeout, ++ }); ++ } ++ ++ get octets(): number { ++ return this.running.weight; ++ } ++ ++ get size(): number { ++ return this.running.size; ++ } ++ ++ /** Whether a message arriving now is refused: at the bound, and until the store is half empty again. */ ++ refuses(): boolean { ++ this.sweep(); ++ ++ if (this.running.full || this.running.weight >= this.maxOctets) { ++ if (!this.atBound) { ++ this.atBound = true; ++ this.log.warn('handledMessages - messages at their bound, refusing new ones until handlers return', { ++ messages: this.size, ++ octets: this.octets, ++ }); ++ } ++ ++ return true; ++ } ++ ++ // Half, so a peer keeping its window full does not flip this on every answer. ++ if (this.atBound && this.size <= this.max / 2 && this.octets <= this.maxOctets / 2) { ++ this.atBound = false; ++ this.log.info('handledMessages - messages down to half their bound, accepting again', { messages: this.size }); ++ } ++ ++ return false; ++ } ++ ++ /** Hands the message to the handler. `retryStatus` answers it where the handler fails first. */ ++ offer(input: SmsInput, handlers: SmsHandlers, retryStatus: ErrorName): Sms { ++ const key = String(this.keys++); ++ const sms = createSms(input, handlers); ++ ++ this.sweep(); ++ this.running.set(key, sms); ++ this.running.weigh(key, input.pduObjs.reduce((sum, pduObj) => sum + retainedOctets(pduObj), 0)); ++ void this.run(key, sms, retryStatus); ++ ++ return sms; ++ } ++ ++ /** Drops every message: their segments went with the link, so no answer of ours correlates now. */ ++ clear(): void { ++ this.running.takeAll(); ++ this.idleWaiters.settle(); ++ } ++ ++ /** Resolves 0 once every handler has settled, or with how many have not. */ ++ idle(timeout: number, signal: AbortSignal | undefined): Promise { ++ return this.idleWaiters.wait(() => this.running.size, timeout, signal); ++ } ++ ++ /** Drops every message past its deadline. Runs before each offer and on its own timer. */ ++ sweep(): void { ++ const expired = this.running.takeExpired(); ++ ++ if (expired.length === 0) return; ++ ++ this.log.warn('handledMessages - handlers still running past their deadline', { messages: expired.length }); ++ this.settle(); ++ } ++ ++ private async run(key: string, sms: Sms, retryStatus: ErrorName): Promise { ++ const failure = await this.handle(sms); ++ ++ if (failure) { ++ this.log.error('handledMessages - a handler failed', { message: failure.message }); ++ this.report(failure); ++ } ++ ++ // A handler that returned has taken the message; one that failed first has decided nothing. ++ if (!sms.answered) { ++ const answered = await sms.sendResp(failure ? { status: retryStatus } : {}); ++ ++ if (answered.err) { ++ this.log.warn('handledMessages - could not answer a message its handler settled', { message: answered.err.message }); ++ } ++ } ++ ++ if (this.running.get(key) !== sms) return; ++ ++ this.running.delete(key); ++ this.settle(); ++ } ++ ++ private async handle(sms: Sms): Promise { ++ if (!this.onSms) return new Error('No onSms handler takes inbound messages'); ++ ++ try { ++ await this.onSms(sms); ++ ++ return undefined; ++ } catch (thrown: unknown) { ++ return errorFrom(thrown); ++ } ++ } ++ ++ private settle(): void { ++ if (this.running.size === 0) this.idleWaiters.settle(); ++ } ++} +diff --git a/src/held-messages.ts b/src/held-messages.ts +deleted file mode 100644 +index b9e740e..0000000 +--- a/src/held-messages.ts ++++ /dev/null +@@ -1,201 +0,0 @@ +-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'; +-import type { SmsHandlers } from './sms.ts'; +-import type { SmppLog } from './log.ts'; +-import { ExpiringGroups } from './expiring-groups.ts'; +-import { IdleWaiters } from './idle-waiters.ts'; +-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; +- sendPastDrain: SmsHandlers['send']; +- session: Session; +- timeout: number; +-}; +- +-/** The peer's own sequence number, which is what our answer to this message will carry. */ +-function keyOf(pduObjs: PduObject[]): string | undefined { +- const first = pduObjs[0]; +- +- return first ? String(first.seqNr) : undefined; +-} +- +-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. +- */ +-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> { +- 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; +- 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(); +- private readonly route: HoldRoute; +- +- constructor(options: HeldMessagesOptions) { +- this.held = new ExpiringGroups({ +- max: options.max, +- now: options.now, +- 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 }; +- } +- +- get octetsHeld(): number { +- return this.held.weight; +- } +- +- get size(): number { +- return this.held.size; +- } +- +- /** Whether a message arriving now is past the bound, once the expired are swept. */ +- 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); +- +- this.sweep(); +- +- if (this.held.get(key)) { +- this.log.warn('heldMessages - replacing a message on a re-used sequence number', { seqNr: Number(key) }); +- } +- +- this.held.set(key, pduObjs); +- this.held.weigh(key, pduObjs.reduce((sum, pduObj) => sum + retainedOctets(pduObj), 0)); +- +- return hold; +- } +- +- offer(pduObjs: PduObject[], answeredAs?: string): MessageHold | 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); +- +- this.offered.set(sms, hold); +- +- if (!this.route.session.emit('sms', sms)) hold.release(); +- +- return hold; +- } +- +- /** 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(); +- } +- +- holds(pduObjs: PduObject[]): boolean { +- const key = keyOf(pduObjs); +- +- return key !== undefined && this.held.get(key) === pduObjs; +- } +- +- release(pduObjs: PduObject[]): void { +- const key = keyOf(pduObjs); +- +- // Identity, not the key: a wrapped sequence number must not release someone else's message. +- if (key === undefined || this.held.get(key) !== pduObjs) return; +- +- this.held.delete(key); +- this.settle(); +- } +- +- /** Drops every message: their segments went with the link, so no answer of ours correlates now. */ +- clear(): void { +- this.held.takeAll(); +- this.idleWaiters.settle(); +- } +- +- /** Resolves 0 once every message has been answered, or with how many have not. */ +- idle(timeout: number, signal: AbortSignal | undefined): Promise { +- 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(); +- +- if (expired.length === 0) return; +- +- this.log.warn('heldMessages - messages the application never answered', { +- messages: expired.length, +- }); +- this.settle(); +- } +- +- private settle(): void { +- if (this.held.size === 0) this.idleWaiters.settle(); +- } +-} +diff --git a/src/incoming-requests.ts b/src/incoming-requests.ts +index 51aeec7..fd59feb 100644 +--- a/src/incoming-requests.ts ++++ b/src/incoming-requests.ts +@@ -1,19 +1,19 @@ + 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 { LinkLife } from './link-life.ts'; + import type { LostGroup, Refusal } from './reassembly.ts'; +-import type { OnRequest } from './session-options.ts'; ++import type { OnRequest, OnSms } from './session-options.ts'; + import type { PduObject } from './pdu.ts'; +-import type { VoidResult } from './result.ts'; + import type { Session } from './session.ts'; ++import type { SmsHandlers } from './sms.ts'; + import type { SmppLog } from './log.ts'; + import type { SmsIdFormat } from './sms-id.ts'; +-import { HeldMessages } from './held-messages.ts'; ++import type { VoidResult } from './result.ts'; ++import { HandledMessages } from './handled-messages.ts'; + import { Reassembler } from './reassembly.ts'; +-import { bindCommands, defaults, standsInFor } from './session-options.ts'; ++import { bindCommands, standsInFor } from './session-options.ts'; + import { concatOf } from './concat.ts'; ++import { defaults } from './defaults.ts'; + import { detach } from './retained-pdu.ts'; + import { dlrFromPdu } from './dlr.ts'; + import { respIdParams, segmentId } from './sms-id.ts'; +@@ -44,14 +44,19 @@ const lostReasons: Record = { + }; + + export type IncomingRequestsOptions = { ++ /** Writes one response, on the link the request arrived on. */ ++ answer: SmsHandlers['answer']; + dlrMerger: DlrMerger; +- link: LinkLife; + log: SmppLog; + maxOctets?: number | undefined; + maxReassembly?: number | undefined; ++ now?: (() => number) | undefined; + onRequest?: OnRequest | undefined; ++ onSms?: OnSms | undefined; + reassemblyTimeout?: number | undefined; +- sendPastDrain: HeldMessagesOptions['sendPastDrain']; ++ /** A request that goes out during a drain too: what a receipt for a message being handled takes. */ ++ send: SmsHandlers['send']; ++ /** Handed to the hook and to every Sms, and asked which end it is and what its bind carries. */ + session: Session; + smsIdFormat?: SmsIdFormat | undefined; + systemId?: string | undefined; +@@ -60,51 +65,51 @@ export type IncomingRequestsOptions = { + /** Everything the peer asks of a session: messages, receipts, links and the answers to them. */ + export class IncomingRequests { + private readonly dlrMerger: DlrMerger; +- private readonly held: HeldMessages; +- private readonly link: LinkLife; ++ private readonly handled: HandledMessages; ++ private readonly handlers: SmsHandlers; + private readonly log: SmppLog; + private readonly onRequest: OnRequest | undefined; + private readonly reassembler: Reassembler; + 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, ++ this.handled = new HandledMessages({ + log: options.log, +- max: defaults.maxHeldMessages, +- maxOctets: defaults.maxHeldOctets, +- sendPastDrain: options.sendPastDrain, +- session: options.session, +- timeout: defaults.heldMessageTimeout, ++ max: defaults.session.maxHandledMessages, ++ maxOctets: defaults.session.maxHandledOctets, ++ now: options.now, ++ onSms: options.onSms, ++ report: err => { options.session.emit('sessionError', err); }, ++ timeout: defaults.session.handledMessageTimeout, + }); +- this.link = options.link; ++ this.handlers = { answer: options.answer, send: options.send }; + this.log = options.log; + this.onRequest = options.onRequest; + this.reassembler = new Reassembler({ + log: options.log, +- max: options.maxReassembly ?? defaults.maxReassembly, ++ max: options.maxReassembly ?? defaults.session.maxReassembly, + maxOctets: options.maxOctets, ++ now: options.now, + onLost: lost => { this.reportLost(lost); }, +- timeout: options.reassemblyTimeout ?? defaults.reassemblyTimeout, ++ timeout: options.reassemblyTimeout ?? defaults.session.reassemblyTimeout, + }); + this.session = options.session; + this.smsIdFormat = options.smsIdFormat ?? {}; +- this.systemId = options.systemId ?? defaults.systemId; ++ this.systemId = options.systemId ?? defaults.session.systemId; + } + + async handle(pduObj: PduObject): Promise { +- const generation = this.link.generation(); ++ const link = this.session.sock; + const { onRequest } = this; + + // Called unbound, so the application's hook never sees this class as its `this`. + if (onRequest && await onRequest(this.session, pduObj)) return; + + // The link it arrived on went while the hook ran, so nothing we answer now correlates. +- if (this.link.generation() !== generation) { ++ if (link.destroyed) { + this.log.info('session - dropping a request whose link went', { cmdName: pduObj.cmdName }); + + return; +@@ -115,7 +120,7 @@ export class IncomingRequests { + bindType: this.session.boundAs ?? '', + cmdName: pduObj.cmdName, + }); +- await this.session.sendReturn(pduObj, 'ESME_RINVBNDSTS'); ++ this.handlers.answer(pduObj, 'ESME_RINVBNDSTS', {}); + + return; + } +@@ -128,52 +133,47 @@ export class IncomingRequests { + case 'data_sm': + case 'deliver_sm': + // A data_sm at the SMSC end is a submission, and a submission is never a report. +- await (this.carriedAs(pduObj) === 'submit_sm' +- ? this.onMessage(pduObj) +- : this.onDelivery(pduObj)); ++ if (this.carriedAs(pduObj) === 'submit_sm') this.onMessage(pduObj); ++ else this.onDelivery(pduObj); ++ + break; + case 'enquire_link': +- await this.session.sendReturn(pduObj); ++ this.handlers.answer(pduObj, 'ESME_ROK', {}); + break; + case 'submit_sm': +- await this.onMessage(pduObj); ++ this.onMessage(pduObj); + break; + case 'unbind': +- await this.session.sendReturn(pduObj); ++ this.handlers.answer(pduObj, 'ESME_ROK', {}); + // A peer that has said it is finished will not answer what we still have outstanding. + await this.session.close({ signal: AbortSignal.abort() }); + break; + default: +- await this.unhandled(pduObj); ++ this.unhandled(pduObj); + } + } + +- /** 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, and the count of every one being handled. */ + clear(): void { +- this.refusing = false; +- this.held.clear(); ++ this.handled.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. */ ++ /** Waits out the handlers still running, and says how many never settled. */ + async drain(timeout: number, signal: AbortSignal | undefined): Promise { +- const unanswered = await this.held.idle(timeout, signal); ++ const unsettled = await this.handled.idle(timeout, signal); + +- if (unanswered === 0) return {}; ++ if (unsettled === 0) return {}; + +- this.log.warn('session - shutting down with messages unanswered', { timeout, unanswered }); ++ this.log.warn('session - shutting down with messages still being handled', { timeout, unsettled }); + +- return { err: new Error(`Shut down with ${String(unanswered)} message(s) unanswered`) }; ++ return { err: new Error(`Shut down with ${String(unsettled)} message(s) still being handled`) }; + } + +- private async unhandled(pduObj: PduObject): Promise { ++ private unhandled(pduObj: PduObject): void { + if (bindCommands.includes(pduObj.cmdName)) { + this.log.info('session - bind on an already bound session', { cmdName: pduObj.cmdName }); +- await this.session.sendReturn(pduObj, 'ESME_RALYBND', { system_id: this.systemId }); ++ this.handlers.answer(pduObj, 'ESME_RALYBND', { system_id: this.systemId }); + + return; + } +@@ -185,7 +185,7 @@ export class IncomingRequests { + } + + this.log.info('session - no handler for command', { cmdName: pduObj.cmdName }); +- await this.session.sendReturn(pduObj, 'ESME_RINVCMDID'); ++ this.handlers.answer(pduObj, 'ESME_RINVCMDID', {}); + } + + private carriedAs(pduObj: PduObject): string { +@@ -193,11 +193,11 @@ export class IncomingRequests { + } + + /** SMPP carries a mobile-originated message and a delivery receipt on the same command. */ +- private async onDelivery(pduObj: PduObject): Promise { ++ private onDelivery(pduObj: PduObject): void { + const dlr = dlrFromPdu(pduObj, this.smsIdFormat); + + if (!dlr) { +- await this.onMessage(pduObj); ++ this.onMessage(pduObj); + + return; + } +@@ -208,52 +208,30 @@ export class IncomingRequests { + + if (merged) this.session.emit('messageDlr', merged); + +- await this.session.sendReturn(pduObj); ++ this.handlers.answer(pduObj, 'ESME_ROK', {}); + } + +- private async refusedAtBound(pduObj: PduObject): Promise { +- 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', { ++ /** ++ * A concatenated message is answered segment by segment as it arrives: a peer that dispatches ++ * one request at a time never sends the second segment until the first has been answered. ++ */ ++ private onMessage(pduObj: PduObject): void { ++ const carriedAs = this.carriedAs(pduObj); ++ ++ if (this.handled.refuses()) { ++ this.log.verbose('session - 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 true; +- } ++ this.handlers.answer(pduObj, throttledStatus(carriedAs), {}); + +- // 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 }); ++ return; + } + +- return false; +- } +- +- /** +- * A concatenated message is answered segment by segment as it arrives: a peer that dispatches +- * one request at a time never sends the second segment until the first has been answered. +- */ +- private async onMessage(pduObj: PduObject): Promise { +- if (await this.refusedAtBound(pduObj)) return; +- + const concat = concatOf(pduObj); + + if (!concat) { +- this.held.offer([detach(pduObj)]); ++ this.offer([detach(pduObj)], undefined, carriedAs); + + return; + } +@@ -261,21 +239,26 @@ export class IncomingRequests { + const collected = this.reassembler.collect(pduObj, concat); + + if (!collected.kept) { +- await this.session.sendReturn( +- pduObj, +- refusedSegmentStatus(this.carriedAs(pduObj), collected.refusal, concat.spelling), +- ); ++ this.handlers.answer(pduObj, refusedSegmentStatus(carriedAs, collected.refusal, concat.spelling), {}); + + return; + } + +- await this.session.sendReturn( ++ this.handlers.answer( + pduObj, + 'ESME_ROK', + respIdParams(pduObj.cmdName, segmentId(collected.smsId, concat.part - 1, concat.total)), + ); + +- if (collected.whole) this.held.offer(collected.whole, collected.smsId); ++ if (collected.whole) this.offer(collected.whole, collected.smsId, carriedAs); ++ } ++ ++ private offer(pduObjs: PduObject[], answeredAs: string | undefined, carriedAs: string): void { ++ this.handled.offer( ++ { answeredAs, link: this.session.sock, pduObjs, session: this.session }, ++ this.handlers, ++ throttledStatus(carriedAs), ++ ); + } + + private reportLost(lost: LostGroup): void { +diff --git a/src/index.ts b/src/index.ts +index 71fa973..b3e8a20 100644 +--- a/src/index.ts ++++ b/src/index.ts +@@ -52,7 +52,9 @@ export type { + } from './server.ts'; + export type { + CloseOptions, ++ LinkState, + MessageDlr, ++ OnSms, + ReconnectOptions, + SendOptions, + SendSmsOptions, +diff --git a/src/link-life.ts b/src/link-life.ts +deleted file mode 100644 +index f44f2ea..0000000 +--- a/src/link-life.ts ++++ /dev/null +@@ -1,188 +0,0 @@ +-import type { SmppLog } from './log.ts'; +-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(). */ +- 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'; +- +-type Waiter = (result: VoidResult) => void; +- +-function aborted(): Error { +- return new Error('Aborted while waiting for a link'); +-} +- +-function expired(): Error { +- return new Error('The link did not come back in time'); +-} +- +-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. */ +-export class LinkLife { +- private readonly log: SmppLog; +- private readonly now: () => number; +- private readonly reconnects: boolean; +- private readonly timeout: number; +- private readonly waiting = new Set(); +- private drops = 0; +- private phase: Phase = 'up'; +- private stopped = false; +- +- constructor(options: LinkLifeOptions) { +- this.log = options.log; +- this.now = options.now ?? Date.now; +- this.reconnects = options.reconnects; +- this.timeout = options.timeout; +- } +- +- /** A socket is on the link, bound or not. */ +- isAttached(): boolean { +- return this.phase === 'binding' || this.phase === 'up'; +- } +- +- /** Whether a request can go out right now. */ +- isUp(): boolean { +- return this.phase === 'up'; +- } +- +- private isOver(): boolean { +- return this.phase === 'ended'; +- } +- +- /** The session is shutting down: nothing new is taken, and no link follows this one. */ +- isStopped(): boolean { +- return this.stopped; +- } +- +- /** Whether a link that drops now is followed by another. */ +- retrying(): boolean { +- return this.reconnects && !this.stopped; +- } +- +- /** Not up and not over, with a link to come. */ +- awaitsNextLink(): boolean { +- return !this.isUp() && !this.isOver() && this.retrying(); +- } +- +- /** Changes with every drop, so what was read off one link can tell that link is gone. */ +- generation(): number { +- return this.drops; +- } +- +- /** 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(); +- } +- +- /** One budget for a request, however many links it waits through. */ +- hold(signal: AbortSignal | undefined): () => Promise { +- 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'; +- } +- +- /** The link is bound: everything held goes out on it. */ +- open(): void { +- this.phase = 'up'; +- +- if (this.waiting.size > 0) { +- this.log.verbose('linkLife - sending what was held for a link', { held: this.waiting.size }); +- } +- +- this.release({}); +- } +- +- /** 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; +- +- this.phase = 'down'; +- this.drops++; +- +- return this.retrying() ? 'disconnected' : 'close'; +- } +- +- stop(): void { +- this.stopped = true; +- } +- +- /** The session is over: nothing held will ever go out. False means it already was. */ +- end(): boolean { +- if (this.isOver()) return false; +- +- this.phase = 'ended'; +- this.stopped = true; +- this.release({ err: over() }); +- +- return true; +- } +- +- /** Resolves once a link can carry the request, or with the reason none ever will. */ +- private wait(deadline: number, signal: AbortSignal | undefined): Promise { +- if (this.isUp()) return Promise.resolve({}); +- +- const refused = this.refusal(); +- +- if (refused) return Promise.resolve({ err: refused }); +- +- if (signal?.aborted === true) return Promise.resolve({ err: aborted() }); +- +- const left = deadline === 0 ? 0 : deadline - this.now(); +- +- if (deadline !== 0 && left <= 0) return Promise.resolve({ err: expired() }); +- +- return this.waitForLink(left, signal); +- } +- +- private waitForLink(left: number, signal: AbortSignal | undefined): Promise { +- this.log.verbose('linkLife - holding a request until a link is back', { timeout: left }); +- +- return new Promise(resolve => { +- let timer: NodeJS.Timeout | undefined = undefined; +- const settle = (result: VoidResult): void => { +- if (timer) clearTimeout(timer); +- +- signal?.removeEventListener('abort', onAbort); +- this.waiting.delete(settle); +- resolve(result); +- }; +- const giveUp = (): void => { +- this.log.warn('linkLife - no link came back in time', { timeout: left }); +- settle({ err: expired() }); +- }; +- +- function onAbort(): void { +- settle({ err: aborted() }); +- } +- +- // Not unref()'d: a held request is awaited with no other handle, so the process would exit unsettled. +- if (left > 0) timer = setTimeout(giveUp, left); +- +- signal?.addEventListener('abort', onAbort, { once: true }); +- this.waiting.add(settle); +- }); +- } +- +- private release(result: VoidResult): void { +- for (const settle of [...this.waiting]) { +- settle(result); +- } +- } +-} +diff --git a/src/link-waiters.ts b/src/link-waiters.ts +new file mode 100644 +index 0000000..02d280f +--- /dev/null ++++ b/src/link-waiters.ts +@@ -0,0 +1,71 @@ ++import type { SmppLog } from './log.ts'; ++import type { VoidResult } from './result.ts'; ++ ++type Waiter = (result: VoidResult) => void; ++ ++function aborted(): Error { ++ return new Error('Aborted while waiting for a link'); ++} ++ ++function expired(): Error { ++ return new Error('The link did not come back in time'); ++} ++ ++/** The requests with no bound link to go out on, waiting for the next one. */ ++export class LinkWaiters { ++ private readonly log: SmppLog; ++ private readonly now: () => number; ++ private readonly waiting = new Set(); ++ ++ constructor(log: SmppLog, now: () => number = Date.now) { ++ this.log = log; ++ this.now = now; ++ } ++ ++ get size(): number { ++ return this.waiting.size; ++ } ++ ++ /** Resolves when released, or with the reason it stopped waiting. A deadline of 0 waits for as long as it takes. */ ++ wait(deadline: number, signal: AbortSignal | undefined): Promise { ++ if (signal?.aborted === true) return Promise.resolve({ err: aborted() }); ++ ++ const left = deadline === 0 ? 0 : deadline - this.now(); ++ ++ if (deadline !== 0 && left <= 0) return Promise.resolve({ err: expired() }); ++ ++ this.log.verbose('linkWaiters - holding a request until a link is back', { timeout: left }); ++ ++ return new Promise(resolve => { ++ let timer: NodeJS.Timeout | undefined = undefined; ++ const settle = (result: VoidResult): void => { ++ if (timer) clearTimeout(timer); ++ ++ signal?.removeEventListener('abort', onAbort); ++ this.waiting.delete(settle); ++ resolve(result); ++ }; ++ const giveUp = (): void => { ++ this.log.warn('linkWaiters - no link came back in time', { timeout: left }); ++ settle({ err: expired() }); ++ }; ++ ++ function onAbort(): void { ++ settle({ err: aborted() }); ++ } ++ ++ // Not unref()'d: a held request is awaited with no other handle, so the process would exit unsettled. ++ if (left > 0) timer = setTimeout(giveUp, left); ++ ++ signal?.addEventListener('abort', onAbort, { once: true }); ++ this.waiting.add(settle); ++ }); ++ } ++ ++ /** Everything waiting goes, with the same answer. */ ++ release(result: VoidResult): void { ++ for (const settle of [...this.waiting]) { ++ settle(result); ++ } ++ } ++} +diff --git a/src/message.ts b/src/message.ts +index 0665019..f6913c3 100644 +--- a/src/message.ts ++++ b/src/message.ts +@@ -11,7 +11,7 @@ const singleMessageBits = 1120; + export const maxSegments = 255; + + /** Budget per segment: the 134 octets left of 140 after the UDH, or the 153 septets GSM packs into them. */ +-const segmentUnits: Record = { ASCII: 153, LATIN1: 134, UCS2: 134 }; ++const segmentUnits: Record = { GSM7: 153, LATIN1: 134, UCS2: 134 }; + + export type SplitOptions = { + encoding?: EncodingName; +@@ -70,7 +70,7 @@ export function bitCount(message: string, encoding?: EncodingName): number { + const encoded = encodings[resolved].encode(message); + + // GSM characters are packed seven bits to a septet; everything else stays octet-aligned. +- return resolved === 'ASCII' ? encoded.length * 7 : encoded.length * 8; ++ return resolved === 'GSM7' ? encoded.length * 7 : encoded.length * 8; + } + + /** +diff --git a/src/outgoing-requests.ts b/src/outgoing-requests.ts +index a0adf24..faaa616 100644 +--- a/src/outgoing-requests.ts ++++ b/src/outgoing-requests.ts +@@ -1,9 +1,10 @@ +-import type { LinkLife } from './link-life.ts'; ++import type { LinkState } from './session-life.ts'; + import type { PduObject, PduObjectInput } from './pdu.ts'; + import type { PduTransport } from './pdu-transport.ts'; + import type { Result, VoidResult } from './result.ts'; + import type { SendOptions } from './session-options.ts'; + import type { SmppLog } from './log.ts'; ++import { LinkWaiters } from './link-waiters.ts'; + import { PendingRequests } from './pending-requests.ts'; + import { SendWindow } from './send-window.ts'; + import { UnansweredError } from './unanswered-error.ts'; +@@ -11,22 +12,29 @@ import { bindCommands } from './session-options.ts'; + import { objToPdu } from './pdu.ts'; + + export type OutgoingRequestsOptions = { +- link: LinkLife; + log: SmppLog; + maxOutstanding: number; + responseTimeout: number; ++ /** Read, never copied: the session's life is the one owner of it. */ ++ state: () => LinkState; + transport: PduTransport; + }; + +-/** `retryOnNextLink`: the write failed, so nothing reached the socket and another link may carry it. */ +-type Attempt = { result: Result<{ pduObj: PduObject }>; retryOnNextLink: boolean }; ++type Response = Result<{ pduObj: PduObject }>; ++ ++/** `written` is false where nothing reached the socket, so another link may carry the request. */ ++type Attempt = { result: Response; written: boolean }; + + function abortedBeforeSend(): Error { + return new Error('Aborted before the request was sent'); + } + ++function over(): Error { ++ return new Error('Session is closed'); ++} ++ + /** A response carries the request's sequence number, which only sendReturn() has. */ +-function misuse(input: PduObjectInput): Error | undefined { ++export function misuse(input: PduObjectInput): Error | undefined { + return input.cmdName.endsWith('_resp') + ? new Error(`Use sendReturn() for responses, not send(): ${input.cmdName}`) + : undefined; +@@ -34,24 +42,31 @@ 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 state: () => LinkState; + private readonly transport: PduTransport; ++ private readonly waiters: LinkWaiters; + private readonly window: SendWindow; + + constructor(options: OutgoingRequestsOptions) { +- this.link = options.link; + this.log = options.log; + this.pending = new PendingRequests(options.log); + this.responseTimeout = options.responseTimeout; ++ this.state = options.state; + this.transport = options.transport; ++ this.waiters = new LinkWaiters(options.log); + this.window = new SendWindow({ limit: options.maxOutstanding, log: options.log }); + } + +- canCarry(): boolean { +- return this.link.isUp() && !this.transport.sock.destroyed; ++ /** The link is bound: everything held for one goes out on it. */ ++ linkUp(): void { ++ if (this.waiters.size > 0) { ++ this.log.verbose('outgoingRequests - sending what was held for a link', { held: this.waiters.size }); ++ } ++ ++ this.waiters.release({}); + } + + /** The link is gone, and every answer still owed on it with it. */ +@@ -59,6 +74,12 @@ export class OutgoingRequests { + this.pending.settleAll(new Error('Session closed before a response arrived')); + } + ++ /** The session is over: nothing held will ever go out. */ ++ end(): void { ++ this.linkLost(); ++ this.waiters.release({ err: over() }); ++ } ++ + /** Hands a response to the request waiting for it. False means nothing was. */ + deliver(pduObj: PduObject): boolean { + return this.pending.deliver(pduObj); +@@ -69,58 +90,48 @@ export class OutgoingRequests { + this.pending.settle(seqNr, { err }); + } + +- request(input: PduObjectInput, options: SendOptions): Promise> { +- // Ahead of the drain, so a misuse is named as one rather than blamed on the shutdown. +- const wrong = misuse(input); +- +- if (wrong) return Promise.resolve({ err: wrong }); +- +- // With no link, the request is refused as closed further on. +- if (this.link.isStopped() && this.canCarry()) { +- return Promise.resolve({ err: new Error('Session is shutting down') }); +- } ++ /** ++ * Waits for a bound link and a window slot, then sends. What never reached the socket waits for ++ * the next link; what did is answered or reported unanswered, never sent again. ++ */ ++ async request(input: PduObjectInput, options: SendOptions): Promise { ++ // Before the link and the window, or an aborted call waits for what it will never use. ++ const refused = misuse(input) ?? (options.signal?.aborted === true ? abortedBeforeSend() : undefined); + +- return this.requestPastDrain(input, options); +- } ++ if (refused) return { err: refused }; + +- /** request() without the drain's refusal, which a receipt for a held message has to take. */ +- async requestPastDrain( +- input: PduObjectInput, +- options: SendOptions, +- ): Promise> { +- const refused = this.refuse(input, options); ++ // A bind is what makes a link usable, so it cannot wait for one. ++ if (bindCommands.includes(input.cmdName)) return this.requestOnLink(input, options); + +- if (refused) return { err: refused }; ++ const deadline = this.responseTimeout > 0 ? Date.now() + this.responseTimeout : 0; + +- // 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(); ++ for (;;) { ++ const sent = await this.sendOnce(input, options, deadline); + +- return shut ? { err: shut } : this.requestOnCurrentLink(input, options); ++ if (sent) return sent; + } ++ } + +- const waitForLink = this.link.hold(options.signal); +- +- for (;;) { +- const held = await waitForLink(); ++ /** One pass at the link and the window. Undefined means nothing reached the socket and a link is on its way. */ ++ private async sendOnce(input: PduObjectInput, options: SendOptions, deadline: number): Promise { ++ const link = await this.waitForLink(deadline, options.signal); + +- if (held.err) return { err: held.err }; ++ if (link.err) return { err: link.err }; + +- const slot = await this.window.acquire(options.signal); ++ const slot = await this.window.acquire(options.signal); + +- if (slot.err) return { err: slot.err }; ++ if (slot.err) return { err: slot.err }; + +- const attempt = await this.attempt(input, options).finally(() => { this.window.release(); }); ++ const attempt = await this.attempt(input, options).finally(() => { this.window.release(); }); + +- if (!this.retriesOnNextLink(attempt)) return attempt.result; +- } ++ // Only a link on its way back is worth waiting for; on any other state the failure stands. ++ return attempt.written || this.state() !== 'down' ? attempt.result : undefined; + } + +- /** Straight onto the current link, for what has to go out either way. */ +- async requestOnCurrentLink( +- input: PduObjectInput, +- options: SendOptions = {}, +- ): Promise> { ++ /** Straight onto the attached socket, outside the window: a bind or an unbind. */ ++ async requestOnLink(input: PduObjectInput, options: SendOptions = {}): Promise { ++ if (this.state() === 'ended') return { err: over() }; ++ + return (await this.attempt(input, options)).result; + } + +@@ -135,28 +146,29 @@ export class OutgoingRequests { + return { err: new Error(`Shut down with ${String(unfinished)} request(s) unfinished`) }; + } + +- /** 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(); +- } +- +- /** Why a request cannot go out at all, as opposed to not yet. */ +- private refuse(input: PduObjectInput, options: SendOptions): Error | undefined { +- // Before the link and the window, or an aborted call waits for what it will never use. +- return misuse(input) ?? (options.signal?.aborted === true ? abortedBeforeSend() : undefined); ++ private waitForLink(deadline: number, signal: AbortSignal | undefined): Promise { ++ switch (this.state()) { ++ case 'bound': ++ case 'closing': ++ return Promise.resolve({}); ++ case 'ended': ++ return Promise.resolve({ err: over() }); ++ case 'connected': ++ case 'down': ++ return this.waiters.wait(deadline, signal); ++ } + } + + private async attempt(input: PduObjectInput, options: SendOptions): Promise { + // pending.wait() alone settles the caller while the request still goes out to the peer. + if (options.signal?.aborted === true) { +- return { result: { err: abortedBeforeSend() }, retryOnNextLink: false }; ++ return { result: { err: abortedBeforeSend() }, written: false }; + } + + const seqNr = this.pending.nextSeqNr(); + const built = objToPdu({ ...input, seqNr }); + +- if (built.err) return { result: { err: built.err }, retryOnNextLink: false }; ++ if (built.err) return { result: { err: built.err }, written: false }; + + const response = this.pending.wait(seqNr, { + signal: options.signal, +@@ -167,12 +179,12 @@ export class OutgoingRequests { + if (written.err) { + this.pending.settle(seqNr, { err: written.err }); + +- return { result: { err: written.err }, retryOnNextLink: true }; ++ return { result: { err: written.err }, written: false }; + } + + const answered = await response; + + // It went out, so a failure now means the peer may have taken it and the answer was the loss. +- return { result: answered.err ? { err: new UnansweredError(answered.err) } : answered, retryOnNextLink: false }; ++ return { result: answered.err ? { err: new UnansweredError(answered.err) } : answered, written: true }; + } + } +diff --git a/src/reassembly.ts b/src/reassembly.ts +index 4f3d2c5..710d7bb 100644 +--- a/src/reassembly.ts ++++ b/src/reassembly.ts +@@ -2,6 +2,7 @@ import type { Concat } from './concat.ts'; + import type { PduObject } from './pdu.ts'; + import type { SmppLog } from './log.ts'; + import { ExpiringGroups } from './expiring-groups.ts'; ++import { defaults } from './defaults.ts'; + import { decodeMessage } from './message.ts'; + import { detach, retainedOctets } from './retained-pdu.ts'; + import { messageOctets } from './message-body.ts'; +@@ -42,8 +43,6 @@ export type Collected = + whole?: PduObject[] | undefined; + }; + +-export const defaultMaxOctets = 64 * 1024 * 1024; +- + type Group = { + parts: Map; + smsId: string; +@@ -89,7 +88,7 @@ export class Reassembler { + private readonly onLost: (lost: LostGroup) => void; + + constructor(options: ReassemblerOptions) { +- this.maxOctets = options.maxOctets ?? defaultMaxOctets; ++ this.maxOctets = options.maxOctets ?? defaults.session.maxReassemblyOctets; + this.groups = new ExpiringGroups({ + max: options.max, + maxWeight: this.maxOctets, +diff --git a/src/reconnect-loop.ts b/src/reconnect-loop.ts +deleted file mode 100644 +index af8044d..0000000 +--- a/src/reconnect-loop.ts ++++ /dev/null +@@ -1,143 +0,0 @@ +-import type { Result, VoidResult } from './result.ts'; +-import type { SmppLog } from './log.ts'; +-import type { Socket } from 'node:net'; +- +-export const backoffDefaults = { +- maxDelay: 30_000, +- minDelay: 1000, +-}; +- +-export type ReconnectLoopOptions = { +- connect: () => Promise>; +- log: SmppLog; +- maxDelay?: number | undefined; +- minDelay?: number | undefined; +- now?: (() => number) | undefined; +- /** Brings the owner back up on a freshly opened socket. An err means try again. */ +- onConnected: (sock: Socket) => Promise; +- /** Whether the wait between attempts lets the process exit. Default true. */ +- unref?: boolean | undefined; +-}; +- +-/** Reopens a dropped connection, backing off between attempts until it is told to stop. */ +-export class ReconnectLoop { +- private readonly maxDelay: number; +- private readonly minDelay: number; +- private readonly now: () => number; +- private readonly options: ReconnectLoopOptions; +- private attempting = false; +- private delay: number; +- private halted = false; +- private timer: NodeJS.Timeout | undefined; +- private upAt: number | undefined; +- +- constructor(options: ReconnectLoopOptions) { +- this.maxDelay = options.maxDelay ?? backoffDefaults.maxDelay; +- this.minDelay = options.minDelay ?? backoffDefaults.minDelay; +- this.now = options.now ?? Date.now; +- this.options = options; +- this.delay = this.minDelay; +- } +- +- /** Read through a method: stop() can land while an attempt is awaiting. */ +- private isStopped(): boolean { +- return this.halted; +- } +- +- schedule(): void { +- if (this.timer || this.attempting || this.isStopped()) return; +- +- // Coming up is not proof: a stream we cannot read is only found once the link is bound. +- if (this.upAt !== undefined && this.now() - this.upAt >= this.maxDelay) { +- this.delay = this.minDelay; +- } +- +- this.upAt = undefined; +- +- const delay = this.delay; +- +- // Announced when the wait is over rather than when it starts: a cancelled one never happened. +- this.timer = setTimeout(() => { +- this.timer = undefined; +- this.options.log.info('reconnect - retrying', { delay }); +- void this.run(); +- }, delay); +- +- if (this.options.unref ?? true) this.timer.unref(); +- +- this.delay = Math.min(delay * 2, this.maxDelay); +- } +- +- stop(): void { +- this.halted = true; +- +- if (this.timer) clearTimeout(this.timer); +- +- this.timer = undefined; +- } +- +- private async run(): Promise { +- this.attempting = true; +- +- // connect() and onConnected() are the application's, so a throw from either lands here. +- const retry = await this.attempt().catch((thrown: unknown) => { +- const err = thrown instanceof Error ? thrown : new Error(String(thrown)); +- +- this.options.log.error('reconnect - an attempt threw', { message: err.message }); +- +- return true; +- }); +- +- this.attempting = false; +- +- if (retry) this.schedule(); +- } +- +- /** True means the attempt failed and the loop should try again. */ +- private async attempt(): Promise { +- if (this.isStopped()) return false; +- +- const opened = await this.options.connect(); +- +- if (opened.err) { +- this.options.log.warn('reconnect - could not open a socket', { +- message: opened.err.message, +- }); +- +- return true; +- } +- +- if (this.isStopped()) { +- opened.sock.destroy(); +- +- return false; +- } +- +- const up = await this.bringUp(opened.sock); +- +- if (up.err) { +- this.options.log.warn('reconnect - could not come back up', { message: up.err.message }); +- +- return true; +- } +- +- this.upAt = this.now(); +- +- return false; +- } +- +- /** The loop owns the socket until the owner is up on it, so a failed handover must not leak it. */ +- private async bringUp(sock: Socket): Promise { +- try { +- const up = await this.options.onConnected(sock); +- +- if (up.err) sock.destroy(); +- +- return up; +- } catch (thrown: unknown) { +- sock.destroy(); +- +- return { err: thrown instanceof Error ? thrown : new Error(String(thrown)) }; +- } +- } +-} +diff --git a/src/server.ts b/src/server.ts +index 2b9aefe..77a3709 100644 +--- a/src/server.ts ++++ b/src/server.ts +@@ -1,15 +1,15 @@ +-import type { BindType, CloseOptions, OnRequest } from './session-options.ts'; ++import type { BindType, CloseOptions, OnRequest, OnSms } 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'; + 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 { Session } from './session.ts'; + import { bindTypeFromCommand, 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'; ++import { defaults } from './defaults.ts'; + import { errorFrom } from './error-from.ts'; + import { paramText } from './defs/types.ts'; + import { guardedLog } from './log.ts'; +@@ -35,6 +35,8 @@ export type ServerOptions = { + maxReassembly?: number; + /** First refusal on every request a bound peer sends. */ + onRequest?: OnRequest; ++ /** Takes every message a bound peer submits, on every session. */ ++ onSms?: OnSms; + port?: number; + reassemblyTimeout?: number; + responseTimeout?: number; +@@ -49,13 +51,6 @@ export type ServerEvents = { + session: [Session]; + }; + +-const defaults = { +- idleTimeout: 40_000, +- interfaceVersion: defaultInterfaceVersion, +- port: 2775, +- systemId: defaultSystemId, +-}; +- + /** A listener may return a promise: an `async` one that rejects is routed like one that throws. */ + type ServerListener = (...args: ServerEvents[K]) => unknown; + +@@ -166,7 +161,7 @@ function bindRespTlvs(session: Session, options: ServerOptions): TlvInputs | und + if (!session.acceptsOptionalParams()) return undefined; + + return { +- sc_interface_version: { tagValue: options.interfaceVersion ?? defaults.interfaceVersion }, ++ sc_interface_version: { tagValue: options.interfaceVersion ?? defaults.session.interfaceVersion }, + }; + } + +@@ -176,7 +171,7 @@ async function onBind( + bindType: BindType, + options: ServerOptions, + ): Promise { +- const identity = { system_id: options.systemId ?? defaults.systemId }; ++ const identity = { system_id: options.systemId ?? defaults.session.systemId }; + const systemId = paramText(pduObj.params.system_id); + + if (!await authenticate(session, pduObj, options)) { +@@ -233,17 +228,18 @@ async function handleRequest( + function onConnection(sock: Socket, options: ServerOptions, server: SmppServer): void { + const log = guardedLog(options.log); + const session = new Session({ +- idleTimeout: options.idleTimeout ?? defaults.idleTimeout, ++ idleTimeout: options.idleTimeout ?? defaults.server.idleTimeout, + log, + maxOutstanding: options.maxOutstanding, + maxOctets: options.maxOctets, + maxReassembly: options.maxReassembly, + onRequest: (bound, pduObj) => handleRequest(bound, pduObj, options), ++ onSms: options.onSms, + reassemblyTimeout: options.reassemblyTimeout, + responseTimeout: options.responseTimeout, + shutdownTimeout: options.shutdownTimeout, + sock, +- systemId: options.systemId ?? defaults.systemId, ++ systemId: options.systemId ?? defaults.session.systemId, + }); + + session.linkEnd = 'smsc'; +@@ -271,7 +267,7 @@ function createSecureListener(tlsOptions: TlsOptions, log: SmppLog): TlsServer { + + /** A caller without types would otherwise reach a TypeError once per PDU rather than once here. */ + function checkHooks(options: ServerOptions): VoidResult { +- for (const name of ['authenticate', 'onRequest'] as const) { ++ for (const name of ['authenticate', 'onRequest', 'onSms'] as const) { + const hook: unknown = options[name]; + + if (hook !== undefined && typeof hook !== 'function') { +@@ -298,7 +294,7 @@ function checkOptions(options: ServerOptions, log: SmppLog, port: number): VoidR + } + + // An int8 TLV on every bind response: out of range here means no ESME can ever bind. +- const version = options.interfaceVersion ?? defaults.interfaceVersion; ++ const version = options.interfaceVersion ?? defaults.session.interfaceVersion; + + if (!Number.isInteger(version) || version < 0 || version > 0xFF) { + log.warn('server - interface version out of range', { interfaceVersion: version }); +@@ -345,7 +341,7 @@ function onListening(listener: NetServer, smpp: SmppServer, options: ServerOptio + /** Starts listening for SMPP connections. Resolves once the socket is bound. */ + export function server(options: ServerOptions = {}): Promise> { + const log = guardedLog(options.log); +- const port = options.port ?? defaults.port; ++ const port = options.port ?? defaults.server.port; + const created = createListener(options, log, port); + + if (created.err) return Promise.resolve({ err: created.err }); +diff --git a/src/session-life.ts b/src/session-life.ts +new file mode 100644 +index 0000000..b40b913 +--- /dev/null ++++ b/src/session-life.ts +@@ -0,0 +1,204 @@ ++import type { Result, VoidResult } from './result.ts'; ++import type { SmppLog } from './log.ts'; ++import type { Socket } from 'node:net'; ++import { Backoff } from './backoff.ts'; ++import { errorFrom } from './error-from.ts'; ++ ++/** ++ * Where the session is in its life. One value, and every transition is in enter() below: ++ * ++ * connected --bound()---------> bound the link carries everything now ++ * connected --close()---------> ended ++ * connected --link lost-------> down | ended down where a reconnect policy exists ++ * bound -----close()/unbind()-> closing draining; new sends are refused, receipts still go out ++ * bound -----link lost--------> down | ended ++ * closing ---drained----------> ended ++ * closing ---link lost--------> ended ++ * down ------socket opened----> connected then the policy rebinds, which is what reaches bound() ++ * down ------attempt failed---> down waits the backoff out and tries again ++ * down ------close()----------> ended ++ * ++ * `connected` is a socket with no bind on it yet: it carries a bind and nothing else. `ended` is ++ * final. A transition from any other state is ignored, which is what makes a listener that ++ * re-enters here harmless: every event is emitted after its state and effects are in place. ++ */ ++export type LinkState = 'bound' | 'closing' | 'connected' | 'down' | 'ended'; ++ ++/** What a transition does to the rest of the session, each named for the state it serves. */ ++export type LifeEffects = { ++ /** A fresh socket is the link now. */ ++ attach: (sock: Socket) => void; ++ emit: (event: 'close' | 'disconnected' | 'reconnected') => void; ++ /** The link is gone: drop the socket, its timers, and everything that only made sense on it. */ ++ linkDown: () => void; ++ /** The link is bound: everything waiting for one goes out. */ ++ linkUp: () => void; ++ /** The session is over: nothing waiting will ever go out. */ ++ over: () => void; ++}; ++ ++/** How a dropped link is followed by another: open a socket, then bind on it. */ ++export type ReconnectPolicy = { ++ connect: () => Promise>; ++ maxDelay?: number | undefined; ++ minDelay?: number | undefined; ++ rebind: () => Promise; ++}; ++ ++export type SessionLifeOptions = { ++ effects: LifeEffects; ++ log: SmppLog; ++ now?: (() => number) | undefined; ++ reconnect?: ReconnectPolicy | undefined; ++}; ++ ++/** connect() and rebind() are the application's, so a throw from either is a result here. */ ++async function caught(call: () => Promise): Promise { ++ try { ++ return await call(); ++ } catch (thrown: unknown) { ++ return { err: errorFrom(thrown) }; ++ } ++} ++ ++export class SessionLife { ++ state: LinkState = 'connected'; ++ ++ private readonly backoff: Backoff; ++ private readonly effects: LifeEffects; ++ private readonly log: SmppLog; ++ private readonly reconnect: ReconnectPolicy | undefined; ++ /** Counts the sockets this session has had, so the second bind onwards is a reconnect. */ ++ private links = 1; ++ private timer: NodeJS.Timeout | undefined; ++ ++ constructor(options: SessionLifeOptions) { ++ this.backoff = new Backoff({ ...options.reconnect, now: options.now }); ++ this.effects = options.effects; ++ this.log = options.log; ++ this.reconnect = options.reconnect; ++ } ++ ++ /** Whether the link carries requests: bound, or bound and draining. */ ++ carries(): boolean { ++ return this.state === 'bound' || this.state === 'closing'; ++ } ++ ++ /** A socket is on the link. */ ++ attached(): boolean { ++ return this.state !== 'down' && this.state !== 'ended'; ++ } ++ ++ bound(): void { ++ if (this.state === 'connected') this.enter('bound'); ++ } ++ ++ /** Starts the drain. False means there is no bound link to drain, so the caller ends at once. */ ++ closing(): boolean { ++ if (this.state !== 'bound') return false; ++ ++ this.enter('closing'); ++ ++ return true; ++ } ++ ++ end(): void { ++ if (this.state !== 'ended') this.enter('ended'); ++ } ++ ++ linkLost(): void { ++ if (!this.attached()) return; ++ ++ this.enter(this.state !== 'closing' && this.reconnect ? 'down' : 'ended'); ++ } ++ ++ private enter(next: LinkState, sock?: Socket): void { ++ const from = this.state; ++ ++ this.state = next; ++ this.log.debug('session - state', { from, to: next }); ++ ++ switch (next) { ++ case 'bound': ++ this.effects.linkUp(); ++ ++ if (this.links > 1) { ++ this.backoff.linkUp(); ++ this.log.info('session - reconnected'); ++ this.effects.emit('reconnected'); ++ } ++ ++ break; ++ case 'closing': ++ break; ++ case 'connected': ++ if (sock) { ++ this.links++; ++ this.effects.attach(sock); ++ } ++ ++ break; ++ case 'down': ++ this.effects.linkDown(); ++ this.schedule(); ++ this.effects.emit('disconnected'); ++ break; ++ case 'ended': ++ if (this.timer) clearTimeout(this.timer); ++ ++ this.effects.linkDown(); ++ this.effects.over(); ++ this.effects.emit('close'); ++ } ++ } ++ ++ /** Read through a method: an await above may have moved the state, which a narrowed field would hide. */ ++ private is(state: LinkState): boolean { ++ return this.state === state; ++ } ++ ++ /** Announced when the wait is over rather than when it starts: a cancelled one never happened. */ ++ private schedule(): void { ++ if (this.timer) return; ++ ++ const delay = this.backoff.next(); ++ ++ this.timer = setTimeout(() => { ++ this.timer = undefined; ++ this.log.info('reconnect - retrying', { delay }); ++ void this.attempt(); ++ }, delay); ++ this.timer.unref(); ++ } ++ ++ /** The one continuation that re-enters the machine after an await, so it checks what it came back to. */ ++ private async attempt(): Promise { ++ if (!this.reconnect) return; ++ ++ const { connect, rebind } = this.reconnect; ++ const opened = await caught(connect); ++ ++ if (!this.is('down')) { ++ if (!opened.err) opened.sock.destroy(); ++ ++ return; ++ } ++ ++ if (opened.err) { ++ this.log.warn('reconnect - could not open a socket', { message: opened.err.message }); ++ this.schedule(); ++ ++ return; ++ } ++ ++ this.enter('connected', opened.sock); ++ ++ const link = this.links; ++ const rebound = await caught(rebind); ++ ++ if (!rebound.err || this.links !== link || !this.is('connected')) return; ++ ++ this.log.warn('reconnect - could not come back up', { message: rebound.err.message }); ++ this.linkLost(); ++ } ++} +diff --git a/src/session-options.ts b/src/session-options.ts +index 0b768c9..b9cc033 100644 +--- a/src/session-options.ts ++++ b/src/session-options.ts +@@ -8,11 +8,11 @@ import type { SmppLog } from './log.ts'; + import type { SmsIdFormat } from './sms-id.ts'; + import type { Sms } from './sms.ts'; + import type { Socket } from 'node:net'; +-import { backoffDefaults } from './reconnect-loop.ts'; +-import { defaultMaxOctets } from './reassembly.ts'; ++import { defaults } from './defaults.ts'; + import { isSmsIdNotation, smsIdNotations, smsIdPlaces } from './sms-id.ts'; + import { namedValue } from './error-from.ts'; + ++/** Facts about the session, which nothing waits on. A message to answer goes to `onSms` instead. */ + export type SessionEvents = { + close: []; + data: [Buffer]; +@@ -23,7 +23,6 @@ export type SessionEvents = { + messageDlr: [MessageDlr]; + reconnected: []; + sessionError: [Error | PduRefusedError]; +- sms: [Sms]; + }; + + export const bindCommands: readonly string[] = [ +@@ -85,6 +84,13 @@ export type CloseOptions = { signal?: AbortSignal | undefined }; + */ + export type OnRequest = (session: Session, pduObj: PduObject) => Promise | boolean; + ++/** ++ * Takes every inbound message. The message is held until the handler settles: it counts toward the ++ * bound and a shutdown waits for it. One that returns has the message answered `ESME_ROK` unless it ++ * answered it itself; one that throws has it refused with the retry status. ++ */ ++export type OnSms = (sms: Sms) => unknown; ++ + /** + * How to come back after an unexpected disconnect. The session owns the retry loop; the caller + * supplies how to open a socket and what to do once it is open (bind, for a client). +@@ -104,6 +110,7 @@ export type SessionOptions = { + maxOutstanding?: number | undefined; + maxReassembly?: number | undefined; + onRequest?: OnRequest | undefined; ++ onSms?: OnSms | undefined; + reassemblyTimeout?: number | undefined; + reconnect?: ReconnectOptions | undefined; + responseTimeout?: number | undefined; +@@ -116,8 +123,6 @@ export type SessionOptions = { + systemId?: string | undefined; + }; + +-export const defaultSystemId = ''; +- + /** SMPP 3.4: a peer that declares no version at all is one from before optional parameters. */ + export const undeclaredInterfaceVersion = 0x00; + +@@ -146,22 +151,6 @@ export function checkedBind(bindType: unknown, declaredVersion: unknown): Result + 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, +- /** The peer gave up on an unanswered message long before this; the bound is against growth. */ +- heldMessageTimeout: 300_000, +- maxDlrMerges: 1000, +- maxHeldMessages: 1000, +- maxHeldOctets: 64 * 1024 * 1024, +- maxOutstanding: 10, +- maxReassembly: 1000, +- reassemblyTimeout: 300_000, +- responseTimeout: 30_000, +- shutdownTimeout: 5000, +- systemId: defaultSystemId, +-}; +- + /** + * A count below 1 does not fail loudly anywhere downstream: `maxOutstanding: 0` leaves every send + * queued behind a slot that is never freed, so a send with no `signal` never settles at all. +@@ -187,12 +176,12 @@ export function checkSessionOptions(options: CheckableOptions): VoidResult { + function limitsOf(options: CheckableOptions): [string, number, number][] { + return [ + ['idleTimeout', options.idleTimeout ?? 0, 0], +- ['maxOctets', options.maxOctets ?? defaultMaxOctets, 1], +- ['maxOutstanding', options.maxOutstanding ?? defaults.maxOutstanding, 1], +- ['maxReassembly', options.maxReassembly ?? defaults.maxReassembly, 1], +- ['reassemblyTimeout', options.reassemblyTimeout ?? defaults.reassemblyTimeout, 0], +- ['responseTimeout', options.responseTimeout ?? defaults.responseTimeout, 0], +- ['shutdownTimeout', options.shutdownTimeout ?? defaults.shutdownTimeout, 0], ++ ['maxOctets', options.maxOctets ?? defaults.session.maxReassemblyOctets, 1], ++ ['maxOutstanding', options.maxOutstanding ?? defaults.session.maxOutstanding, 1], ++ ['maxReassembly', options.maxReassembly ?? defaults.session.maxReassembly, 1], ++ ['reassemblyTimeout', options.reassemblyTimeout ?? defaults.session.reassemblyTimeout, 0], ++ ['responseTimeout', options.responseTimeout ?? defaults.session.responseTimeout, 0], ++ ['shutdownTimeout', options.shutdownTimeout ?? defaults.session.shutdownTimeout, 0], + ]; + } + +@@ -243,8 +232,8 @@ function checkReconnect(reconnect: unknown): VoidResult { + return { err: new Error(`reconnect.fromStart must be true or false, got ${typeof reconnect.fromStart}`) }; + } + +- const maxDelay = delayOr(reconnect.maxDelay, backoffDefaults.maxDelay); +- const minDelay = delayOr(reconnect.minDelay, backoffDefaults.minDelay); ++ const maxDelay = delayOr(reconnect.maxDelay, defaults.reconnect.maxDelay); ++ const minDelay = delayOr(reconnect.minDelay, defaults.reconnect.minDelay); + // A delay of 0 never doubles, so the backoff never starts and every retry lands at once. + const checked = checkLimits([['maxDelay', maxDelay, 1], ['minDelay', minDelay, 1]]); + +diff --git a/src/session.ts b/src/session.ts +index 1fd9b46..202d0c1 100644 +--- a/src/session.ts ++++ b/src/session.ts +@@ -1,9 +1,10 @@ + import type { ErrorName } from './defs/errors.ts'; ++import type { LinkState } from './session-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 { BindType, CloseOptions, LinkEnd, OnSms, ReconnectOptions, SendOptions, SessionBind, 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'; +@@ -11,15 +12,15 @@ import type { Socket } from 'node:net'; + import { DlrMerger } from './dlr-merger.ts'; + import { EventEmitter } from 'node:events'; + 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 { OutgoingRequests, misuse } from './outgoing-requests.ts'; + import { PduTransport } from './pdu-transport.ts'; +-import { ReconnectLoop } from './reconnect-loop.ts'; ++import { SessionLife } from './session-life.ts'; + import { leftOf } from './idle-waiters.ts'; + import { errorFrom } from './error-from.ts'; + import { optionalParamsMinVersion } from './defs/constants.ts'; +-import { bindCarries, bindCommands, checkedBind, defaultSystemId, defaults } from './session-options.ts'; ++import { bindCarries, bindCommands, checkedBind } from './session-options.ts'; ++import { defaults } from './defaults.ts'; + import { isResp, objToPdu, pduReturn } from './pdu.ts'; + import { refusalAnswer } from './pdu-refusal.ts'; + import { guardedLog } from './log.ts'; +@@ -29,6 +30,7 @@ import { ConcatReference } from './udh.ts'; + export type { + CloseOptions, + MessageDlr, ++ OnSms, + ReconnectOptions, + SendOptions, + SendSmsOptions, +@@ -36,8 +38,8 @@ export type { + SessionEvents, + SessionOptions, + }; +-export type { BindType }; +-export { bindCommands, defaultSystemId }; ++export type { BindType, LinkState }; ++export { bindCommands }; + + /** A listener may return a promise: an `async` one that rejects is routed like one that throws. */ + type SessionListener = (...args: SessionEvents[K]) => unknown; +@@ -62,10 +64,9 @@ export class Session extends EventEmitter { + private readonly concatReference = new ConcatReference(); + private readonly dlrMerger: DlrMerger; + private readonly incoming: IncomingRequests; +- private readonly link: LinkLife; ++ private readonly life: SessionLife; + private readonly options: SessionOptions; + private readonly outgoing: OutgoingRequests; +- private readonly reconnectLoop: ReconnectLoop | undefined; + private readonly timers: LinkTimers; + private readonly transport: PduTransport; + +@@ -93,13 +94,11 @@ export class Session extends EventEmitter { + reason: unknown, + ...args: [event: keyof SessionEvents, ...rest: unknown[]] + ): void { +- const [event, ...rest] = args; ++ const [event] = args; + const error = errorFrom(reason); + + this.log.error('session - a listener rejected', { event, message: error.message }); + +- if (event === 'sms') this.incoming.listenerRejected(rest[0]); +- + if (event !== 'sessionError') this.emit('sessionError', error); + } + +@@ -108,43 +107,29 @@ export class Session extends EventEmitter { + + this.log = guardedLog(options.log); + this.options = options; +- this.dlrMerger = new DlrMerger({ log: this.log, max: defaults.maxDlrMerges, timeout: defaults.dlrMergeTimeout }); +- this.reconnectLoop = this.loopFor(options.reconnect); +- +- const responseTimeout = options.responseTimeout ?? defaults.responseTimeout; +- +- this.link = new LinkLife({ log: this.log, reconnects: this.reconnectLoop !== undefined, timeout: responseTimeout }); ++ this.dlrMerger = new DlrMerger({ ++ log: this.log, ++ max: defaults.session.maxDlrMerges, ++ timeout: defaults.session.dlrMergeTimeout, ++ }); + this.timers = new LinkTimers({ + enquireLinkInterval: options.enquireLinkInterval, + 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.life.linkLost(); }, + }); + this.transport = this.transportFor(options.sock); ++ this.life = this.lifeFor(options.reconnect); + this.outgoing = new OutgoingRequests({ +- link: this.link, + log: this.log, +- maxOutstanding: options.maxOutstanding ?? defaults.maxOutstanding, +- responseTimeout, ++ maxOutstanding: options.maxOutstanding ?? defaults.session.maxOutstanding, ++ responseTimeout: options.responseTimeout ?? defaults.session.responseTimeout, ++ state: () => this.life.state, + transport: this.transport, + }); +- this.incoming = new IncomingRequests({ +- dlrMerger: this.dlrMerger, +- 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(); ++ this.incoming = this.incomingFor(options); ++ this.timers.reset(); + } + + /** Replaced on reconnect, so hold the session rather than this. */ +@@ -152,6 +137,11 @@ export class Session extends EventEmitter { + return this.transport.sock; + } + ++ /** Where the session is in its life; `session-life.ts` draws the transitions. */ ++ get state(): LinkState { ++ return this.life.state; ++ } ++ + /** The role the ESME bound with, whichever end of the link this is. Undefined before any bind. */ + get boundAs(): BindType | undefined { + return this.bind?.as; +@@ -162,13 +152,16 @@ export class Session extends EventEmitter { + return this.bind?.peerVersion; + } + +- /** Records a bind this link accepted or had accepted, until the next one. */ ++ /** Records a bind this link accepted or had accepted, which is what lets it carry requests. */ + bound(bindType: string, declaredVersion: unknown): VoidResult { + const checked = checkedBind(bindType, declaredVersion); + +- if (!checked.err) this.bind = checked.bind; ++ if (checked.err) return { err: checked.err }; + +- return checked.err ? { err: checked.err } : {}; ++ this.bind = checked.bind; ++ this.life.bound(); ++ ++ return {}; + } + + /** Whether this session's bind direction carries a command. Consulted by the library's senders. */ +@@ -181,8 +174,13 @@ export class Session extends EventEmitter { + return this.peerInterfaceVersion === undefined || this.peerInterfaceVersion >= optionalParamsMinVersion; + } + +- /** Sends a request and resolves with the peer's response. */ ++ /** Sends a request and resolves with the peer's response. Refused once a shutdown has begun. */ + send(input: PduObjectInput, options: SendOptions = {}): Promise> { ++ // A misuse is named as one ahead of the drain, rather than blamed on the shutdown. ++ if (this.life.state === 'closing' && !misuse(input)) { ++ return Promise.resolve({ err: new Error('Session is shutting down') }); ++ } ++ + return this.outgoing.request(input, options); + } + +@@ -196,22 +194,6 @@ export class Session extends EventEmitter { + 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 { + if (!this.bindAllows('submit_sm')) { + return unsent(new Error('A receiver-bound session does not carry submit_sm')); +@@ -235,25 +217,25 @@ export class Session extends EventEmitter { + */ + async unbind(): Promise { + const drained = await this.drain(undefined); +- const wasOpen = this.link.isAttached(); ++ const wasOpen = this.life.attached(); + const sent = wasOpen +- ? await this.outgoing.requestOnCurrentLink({ cmdName: 'unbind' }) ++ ? await this.outgoing.requestOnLink({ cmdName: 'unbind' }) + : { err: new Error('Session is closed') }; +- const closedOnUnbind = wasOpen && !this.link.isAttached(); ++ const closedOnUnbind = wasOpen && !this.life.attached(); + +- this.end(); ++ this.life.end(); + + return sent.err && !closedOnUnbind ? { err: sent.err } : drained; + } + + /** +- * Closes for good: refuses new sends, waits up to `shutdownTimeout` for the requests already sent +- * and the messages not yet answered, then tears down whatever is left. A session closed this way never reconnects. ++ * Closes for good: refuses new sends, waits up to `shutdownTimeout` for the handlers still ++ * running and the requests already sent, then tears down whatever is left. Never reconnects. + */ + async close(options: CloseOptions = {}): Promise { + const drained = await this.drain(options.signal); + +- this.end(); ++ this.life.end(); + + return drained; + } +@@ -261,140 +243,97 @@ export class Session extends EventEmitter { + private transportFor(sock: Socket): PduTransport { + return new PduTransport({ + log: this.log, +- onClose: () => { this.onClose(); }, +- onData: chunk => { this.onData(chunk); }, ++ onClose: () => { this.life.linkLost(); }, ++ onData: chunk => { this.emit('data', chunk); this.timers.reset(); }, + onError: err => { this.emit('sessionError', err); }, + onFramed: pdu => { this.emit('incomingPdu', pdu); }, + onPdu: pduObj => { this.dispatch(pduObj); }, + onRefused: refused => { this.refuse(refused); }, + onUnreadable: err => { + this.emit('sessionError', err); +- this.teardown(); ++ this.life.linkLost(); + }, + }, sock); + } + +- private loopFor(reconnect: ReconnectOptions | undefined): ReconnectLoop | undefined { +- if (!reconnect) return undefined; +- +- return new ReconnectLoop({ +- connect: reconnect.connect, ++ /** What each transition does to the collaborators above; the machine itself is `session-life.ts`. */ ++ private lifeFor(reconnect: ReconnectOptions | undefined): SessionLife { ++ return new SessionLife({ ++ effects: { ++ attach: sock => { this.transport.attach(sock); this.timers.reset(); }, ++ emit: event => { this.emit(event); }, ++ linkDown: () => { this.linkDown(); }, ++ linkUp: () => { this.timers.reset(); this.outgoing.linkUp(); }, ++ over: () => { this.outgoing.end(); this.dlrMerger.clear(); }, ++ }, + log: this.log, +- maxDelay: reconnect.maxDelay, +- minDelay: reconnect.minDelay, +- onConnected: sock => this.comeBackUp(sock, reconnect.onConnected), ++ reconnect: reconnect && { ++ connect: reconnect.connect, ++ maxDelay: reconnect.maxDelay, ++ minDelay: reconnect.minDelay, ++ rebind: () => reconnect.onConnected(this), ++ }, + }); + } + +- private async comeBackUp( +- sock: Socket, +- bind: (session: Session) => Promise, +- ): Promise { +- this.attach(sock); +- +- const bound = await bind(this); +- +- if (bound.err) { +- this.teardown(); ++ private incomingFor(options: SessionOptions): IncomingRequests { ++ return new IncomingRequests({ ++ answer: (pduObj, status, params) => this.answer(pduReturn(pduObj, status, params), pduObj.cmdName, pduObj.seqNr), ++ dlrMerger: this.dlrMerger, ++ log: this.log, ++ maxOctets: options.maxOctets, ++ maxReassembly: options.maxReassembly, ++ onRequest: options.onRequest, ++ onSms: options.onSms, ++ reassemblyTimeout: options.reassemblyTimeout, ++ send: input => this.outgoing.request(input, {}), ++ session: this, ++ smsIdFormat: options.smsIdFormat, ++ systemId: options.systemId, ++ }); ++ } + +- return { err: bound.err }; +- } ++ /** The link is gone: what only made sense on it goes with it. Every answer still owed settles. */ ++ private linkDown(): void { ++ this.timers.clear(); ++ this.incoming.clear(); ++ this.outgoing.linkLost(); ++ this.sock.destroy(); ++ } + +- // close() can land while the rebind is in flight. +- if (!this.link.retrying()) { +- this.teardown(); ++ private answer(built: Result<{ buffer: Buffer }>, cmdName: string, seqNr: number): VoidResult { ++ const sent = built.err ? { err: built.err } : this.transport.write(built.buffer); + +- return { err: new Error('Session closed while it was coming back up') }; ++ // A peer that unbinds and drops the link takes our response with it; that is not a failure. ++ if (sent.err && this.life.attached()) { ++ this.log.warn('session - could not answer a request', { cmdName, message: sent.err.message, seqNr }); ++ this.emit('sessionError', sent.err); + } + +- 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(); ++ return sent; + } + +- /** Stops new sends and waits out the messages we hold and the requests already issued. */ ++ /** Stops new sends and waits out the handlers running and the requests already issued. */ + private async drain(signal: AbortSignal | undefined): Promise { +- this.stop(); +- + // No bound link, so nothing is on the wire to wait out. +- if (!this.outgoing.canCarry()) return {}; ++ if (!this.life.closing()) return {}; + +- const timeout = this.options.shutdownTimeout ?? defaults.shutdownTimeout; ++ const timeout = this.options.shutdownTimeout ?? defaults.session.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 messages = await this.incoming.drain(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()) { ++ if (this.life.state !== 'closing') { + 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; +- +- 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(); +- } +- +- private onData(chunk: Buffer): void { +- this.emit('data', chunk); +- this.resetTimers(); +- } +- + private dispatch(pduObj: PduObject): void { + if (isResp(pduObj)) { + if (!this.outgoing.deliver(pduObj)) { +@@ -405,15 +344,11 @@ export class Session extends EventEmitter { + } + + this.emit('incomingPduObj', pduObj); +- // Every application hook and listener reached from an incoming PDU funnels through here. ++ // Every application hook reached from an incoming PDU funnels through here. + void this.incoming.handle(pduObj).catch((thrown: unknown) => { + const err = errorFrom(thrown); + +- this.log.error('session - a handler threw', { +- cmdName: pduObj.cmdName, +- message: err.message, +- seqNr: pduObj.seqNr, +- }); ++ this.log.error('session - a handler threw', { cmdName: pduObj.cmdName, message: err.message, seqNr: pduObj.seqNr }); + this.emit('sessionError', err); + }); + } +@@ -433,21 +368,4 @@ export class Session extends EventEmitter { + + 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/src/sms.ts b/src/sms.ts +index 5149ceb..40c50ba 100644 +--- a/src/sms.ts ++++ b/src/sms.ts +@@ -1,8 +1,10 @@ + import type { ErrorName } from './defs/errors.ts'; + import type { MessageState } from './defs/constants.ts'; ++import type { ParamValue } from './defs/types.ts'; + import type { PduObject, PduObjectInput, TlvInputs } from './pdu.ts'; + import type { Result, VoidResult } from './result.ts'; + import type { Session } from './session.ts'; ++import type { Socket } from 'node:net'; + import { UnansweredError } from './unanswered-error.ts'; + import { consts } from './defs/constants.ts'; + import { decodeSegments } from './reassembly.ts'; +@@ -28,15 +30,12 @@ export type SendRespOptions = { + }; + + /** +- * A received SMS, and the handle for answering it. Multipart messages arrive as one Sms carrying +- * every segment's PDU. ++ * A received SMS, and the handle for answering it. A multipart message arrives as one Sms carrying ++ * every segment's PDU, answered segment by segment as they arrived. + */ + export type Sms = { +- /** +- * Whether the peer was answered as the message's segments arrived, which is what a concatenated +- * message needs and a segment count cannot tell you. `sendResp()` then writes nothing. +- */ +- answeredOnArrival: boolean; ++ /** Whether the peer has been answered: by `sendResp()`, on arrival for a multipart message, or once `onSms` returned. */ ++ readonly answered: boolean; + dlr: boolean; + /** GSM 03.38 message class 0: shown on arrival and not stored. */ + flash: boolean; +@@ -46,13 +45,12 @@ export type Sms = { + /** Sends a delivery report back to the sender. Defaults to DELIVERED. */ + sendDlr: (status?: MessageState) => Promise; + /** +- * Answers the message, and says the application is done with it. A concatenated message was +- * answered segment by segment as it arrived, so there it only releases a shutdown's wait and +- * refuses an `smsId` or a refusing `status`. Part of the protocol, not optional. ++ * Answers the message now, `ESME_ROK` under a generated id unless told otherwise. A message ++ * already answered has nothing to do, and refuses an id or a status that would change the answer. + */ + sendResp: (options?: SendRespOptions) => Promise; + session: Session; +- /** The id the segments were answered with, the id `sendResp()` was given, or a generated UUID v7. */ ++ /** The id the message was or will be answered with: the segments' base, the id `sendResp()` was given, or a generated UUID v7. */ + readonly smsId: string; + submitTime: Date; + to: string; +@@ -61,39 +59,49 @@ export type Sms = { + export type SmsInput = { + /** The id base the segments were already answered with; absent leaves the answer to `sendResp()`. */ + answeredAs?: string | undefined; ++ /** The socket the message arrived on: a response correlates on that link and no other. */ ++ link: Socket; + pduObjs: PduObject[]; + session: Session; + }; + ++/** What answering and receipting a message needs from the session it arrived on. */ + export type SmsHandlers = { +- answered: () => void; +- lostLink: () => boolean; ++ /** Writes one response now, on the link the request arrived on. */ ++ answer: (pduObj: PduObject, status: ErrorName, params: Record) => VoidResult; ++ /** A request that goes out during a drain too, since a receipt finishes what the drain waits on. */ + send: (input: PduObjectInput) => Promise>; + }; + + /** GSM 03.38 section 4 gives class 0 immediate display; every other class is stored somewhere. */ + const immediateDisplayClass = 0; + ++/** The one record of whether and how the peer was answered. */ ++type Answer = { smsId: string; status: ErrorName | undefined }; ++ + export function createSms(input: SmsInput, handlers: SmsHandlers): Sms { + const first = input.pduObjs[0]; + const registered = first?.params.registered_delivery; + const dataCoding = first?.params.data_coding; +- const answered = { smsId: input.answeredAs ?? uuidv7() }; ++ const answer: Answer = { ++ smsId: input.answeredAs ?? uuidv7(), ++ status: input.answeredAs === undefined ? undefined : 'ESME_ROK', ++ }; + + const sms: Sms = { +- answeredOnArrival: input.answeredAs !== undefined, ++ get answered(): boolean { ++ return answer.status !== undefined; ++ }, + dlr: typeof registered === 'number' && registered !== 0, + flash: typeof dataCoding === 'number' && messageClassOf(dataCoding) === immediateDisplayClass, + from: paramText(first?.params.source_addr), + message: decodeSegments(input.pduObjs), + pduObjs: input.pduObjs, + sendDlr: status => sendDlr(sms, input.session, handlers, status), +- sendResp: options => (input.answeredAs === undefined +- ? sendResp(sms, input.session, answered, options ?? {}, handlers) +- : answeredOnArrival(options ?? {}, handlers)), ++ sendResp: options => Promise.resolve(sendResp(sms, input.link, answer, options ?? {}, handlers)), + session: input.session, + get smsId(): string { +- return answered.smsId; ++ return answer.smsId; + }, + submitTime: new Date(), + to: paramText(first?.params.destination_addr), +@@ -102,63 +110,49 @@ export function createSms(input: SmsInput, handlers: SmsHandlers): Sms { + return sms; + } + +-/** Every segment went out answered, so the call is what the shutdown waits for and nothing else. */ +-function answeredOnArrival( +- options: SendRespOptions, +- handlers: Pick, +-): Promise { +- if (options.smsId !== undefined) { +- return Promise.resolve({ +- err: new Error('This message\'s id was fixed when its first segment arrived; read sms.smsId'), +- }); ++/** An answer already given cannot change; one asked for again is nothing to do. */ ++function alreadyAnswered(answer: Answer, given: ErrorName, options: SendRespOptions): VoidResult { ++ if (options.smsId !== undefined && options.smsId !== answer.smsId) { ++ return { err: new Error(`This message was already answered under id ${answer.smsId}; read sms.smsId`) }; + } + +- if (options.status !== undefined && options.status !== 'ESME_ROK') { +- return Promise.resolve({ +- err: new Error('Its segments were answered as they arrived, so there is nothing left to refuse; refuse a segment from the onRequest option instead'), +- }); ++ if ((options.status ?? 'ESME_ROK') !== given) { ++ return { err: new Error(`This message was already answered ${given}, and an answer cannot change; refuse a submission from the onRequest option instead`) }; + } + +- handlers.answered(); +- +- return Promise.resolve({}); ++ return {}; + } + +-async function sendResp( ++function sendResp( + sms: Sms, +- session: Session, +- answered: { smsId: string }, ++ link: Socket, ++ answer: Answer, + options: SendRespOptions, +- handlers: Pick, +-): Promise { +- const total = sms.pduObjs.length; +- +- if (total === 0) { +- return { err: new Error('No PDUs to answer') }; +- } +- +- if (options.smsId === '') { +- return { err: new Error('smsId must not be empty') }; +- } +- +- if (options.smsId !== undefined) answered.smsId = options.smsId; ++ handlers: Pick, ++): VoidResult { ++ if (options.smsId === '') return { err: new Error('smsId must not be empty') }; ++ if (answer.status !== undefined) return alreadyAnswered(answer, answer.status, options); ++ if (sms.pduObjs.length === 0) return { err: new Error('No PDUs to answer') }; + + // A response carries the sequence number it was asked on, which the next link knows nothing about. +- if (handlers.lostLink()) { ++ if (link.destroyed) { + return { err: new Error('The link this message arrived on is gone, so nothing would correlate the response') }; + } + +- const results = await Promise.all(sms.pduObjs.map((pduObj, index) => session.sendReturn( +- pduObj, +- options.status ?? 'ESME_ROK', +- respIdParams(pduObj.cmdName, segmentId(answered.smsId, index, total)), +- ))); ++ const smsId = options.smsId ?? answer.smsId; ++ const status = options.status ?? 'ESME_ROK'; ++ const total = sms.pduObjs.length; ++ const failure = sms.pduObjs ++ .map((pduObj, index) => handlers.answer(pduObj, status, respIdParams(pduObj.cmdName, segmentId(smsId, index, total)))) ++ .find(result => result.err); + +- const failure = results.find(result => result.err); ++ // Nothing reached the peer, so the message is still unanswered and the id it was given is not its. ++ if (failure) return failure; + +- if (!failure) handlers.answered(); ++ answer.smsId = smsId; ++ answer.status = status; + +- return failure ?? {}; ++ return {}; + } + + /** The receipt as text, which is all of it a peer below SMPP 3.4 is allowed to be sent. */ +@@ -239,5 +233,6 @@ async function sendDlr( + ...(session.acceptsOptionalParams() ? { tlvs: receiptTlvs(smsId, status) } : {}), + }); + })); ++ + return collectReceipt(sent); + } +diff --git a/test/declared-alphabet.test.ts b/test/declared-alphabet.test.ts +index 8132883..e762ffc 100644 +--- a/test/declared-alphabet.test.ts ++++ b/test/declared-alphabet.test.ts +@@ -21,7 +21,7 @@ const bothTables = 'Cost 5$ @home'; + /** What SMPP 3.4 5.2.19 assigns each coding, read the way a peer honouring the field reads it. */ + const byTheSpecsTable: Record string> = { + // The SMSC default alphabet, which every peer in interop-tests/ runs as GSM 03.38. +- 0x00: octets => encodings.ASCII.decode(octets), ++ 0x00: octets => encodings.GSM7.decode(octets), + // IA5 (CCITT T.50), whose whole range is what Latin-1 reads below 0x80. + [consts.ENCODING.IA5]: octets => octets.toString('latin1'), + }; +@@ -121,17 +121,18 @@ describe('the alphabet a message declares is the one its octets are written in', + + // sendDlr() writes its body as a string with no data_coding, so it takes the detected branch too. + test('declares 0x00 on a receipt it writes itself', async t => { +- const { err, server: smpp } = await server({ port: 0 }); ++ const { err, server: smpp } = await server({ ++ onSms: async sms => { ++ await sms.sendResp(); ++ await sms.sendDlr('DELIVERED'); ++ }, ++ port: 0, ++ }); + + assert.equal(err, undefined); + assert.ok(smpp); + closeAfter(t, smpp); + +- smpp.on('session', peer => peer.on('sms', async sms => { +- await sms.sendResp(); +- await sms.sendDlr('DELIVERED'); +- })); +- + const connected = await client({ port: smpp.port, reconnect: false }); + + assert.equal(connected.err, undefined); +@@ -166,7 +167,7 @@ describe('the alphabet a message declares is the one its octets are written in', + + describe('what a peer declares is read as generously as it was before', () => { + test('reads data_coding 0x01 as GSM 03.38, as 0x00 is read', () => { +- assert.equal(encodingByDataCoding(0x01), 'ASCII'); ++ assert.equal(encodingByDataCoding(0x01), 'GSM7'); + + const octets = Buffer.from('436f73742035022000686f6d65', 'hex'); + +diff --git a/test/encodings.test.ts b/test/encodings.test.ts +index 67fc68c..db908ff 100644 +--- a/test/encodings.test.ts ++++ b/test/encodings.test.ts +@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; + import test, { describe } from 'node:test'; + import { dataCodingByEncoding, detect, encodingByDataCoding, encodings, isEncodingName, unencodable } from '../src/defs/encodings.ts'; + +-describe('ASCII (GSM 03.38)', () => { ++describe('GSM7 (GSM 03.38)', () => { + const samples: [string, number[]][] = [ + ['@£$¥', [0, 1, 2, 3]], + [' 1a=', [0x20, 0x31, 0x61, 0x3D]], +@@ -10,23 +10,23 @@ describe('ASCII (GSM 03.38)', () => { + ]; + + test('matches strings encodable in the GSM 03.38 charset', () => { +- assert.ok(encodings.ASCII.match('')); +- assert.ok(encodings.ASCII.match('@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ\x1BÆæßÉ !"#¤%&\'')); +- assert.ok(encodings.ASCII.match('()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZ')); +- assert.ok(encodings.ASCII.match('ÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà')); +- assert.ok(encodings.ASCII.match('\f^{}\\[~]|€')); ++ assert.ok(encodings.GSM7.match('')); ++ assert.ok(encodings.GSM7.match('@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ\x1BÆæßÉ !"#¤%&\'')); ++ assert.ok(encodings.GSM7.match('()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZ')); ++ assert.ok(encodings.GSM7.match('ÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà')); ++ assert.ok(encodings.GSM7.match('\f^{}\\[~]|€')); + }); + + test('rejects strings outside the GSM 03.38 charset', () => { +- assert.ok(!encodings.ASCII.match('`')); +- assert.ok(!encodings.ASCII.match('ÁáçÚUÓO')); +- assert.ok(!encodings.ASCII.match('تست')); ++ assert.ok(!encodings.GSM7.match('`')); ++ assert.ok(!encodings.GSM7.match('ÁáçÚUÓO')); ++ assert.ok(!encodings.GSM7.match('تست')); + }); + + test('round-trips the sample strings', () => { + for (const [str, bytes] of samples) { +- assert.deepEqual(encodings.ASCII.encode(str), Buffer.from(bytes)); +- assert.equal(encodings.ASCII.decode(Buffer.from(bytes)), str); ++ assert.deepEqual(encodings.GSM7.encode(str), Buffer.from(bytes)); ++ assert.equal(encodings.GSM7.decode(Buffer.from(bytes)), str); + } + }); + }); +@@ -97,8 +97,8 @@ describe('UCS2', () => { + + describe('detect()', () => { + test('picks the narrowest encoding that fits the string', () => { +- assert.equal(detect(''), 'ASCII'); +- assert.equal(detect('ÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà(){}[]'), 'ASCII'); ++ assert.equal(detect(''), 'GSM7'); ++ assert.equal(detect('ÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà(){}[]'), 'GSM7'); + assert.equal(detect('`ÁáçÚUÓO'), 'UCS2'); + assert.equal(detect('«©®µ¶±»'), 'UCS2'); + assert.equal(detect('ʹʺʻʼʽ`'), 'UCS2'); +@@ -122,16 +122,16 @@ describe('detect()', () => { + describe('unencodable()', () => { + test('names the first character an alphabet cannot carry, and nothing where it carries them all', () => { + assert.deepEqual(unencodable('あいう', 'LATIN1'), { char: 'あ', index: 0 }); +- assert.deepEqual(unencodable('Åsa naïve', 'ASCII'), { char: 'ï', index: 6 }); +- assert.equal(unencodable('€{}[]\\~^|\f', 'ASCII'), undefined); +- assert.equal(unencodable('Åsa', 'ASCII'), undefined); ++ assert.deepEqual(unencodable('Åsa naïve', 'GSM7'), { char: 'ï', index: 6 }); ++ assert.equal(unencodable('€{}[]\\~^|\f', 'GSM7'), undefined); ++ assert.equal(unencodable('Åsa', 'GSM7'), undefined); + assert.equal(unencodable('`ÁáçÚ', 'LATIN1'), undefined); + assert.equal(unencodable('あいう😀', 'UCS2'), undefined); + }); + + test('counts the index in the units the message is written in, so a surrogate pair reads back whole', () => { + assert.deepEqual(unencodable('ab😀', 'LATIN1'), { char: '😀', index: 2 }); +- assert.deepEqual(unencodable('a😀b', 'ASCII'), { char: '😀', index: 1 }); ++ assert.deepEqual(unencodable('a😀b', 'GSM7'), { char: '😀', index: 1 }); + }); + + test('carries every octet through Latin-1, which is what an 8-bit binary body is sent as', () => { +@@ -147,36 +147,36 @@ describe('unencodable()', () => { + }); + + test('reads a character at a time, so a bare GSM escape beside its base reads as carried', () => { +- assert.equal(unencodable('\x1Be', 'ASCII'), undefined); +- assert.deepEqual(encodings.ASCII.encode('\x1Be'), encodings.ASCII.encode('€')); ++ assert.equal(unencodable('\x1Be', 'GSM7'), undefined); ++ assert.deepEqual(encodings.GSM7.encode('\x1Be'), encodings.GSM7.encode('€')); + }); + }); + + describe('encodingByDataCoding()', () => { + test('resolves the flat SMPP data_coding table', () => { +- assert.equal(encodingByDataCoding(0x00), 'ASCII'); +- assert.equal(encodingByDataCoding(0x01), 'ASCII'); ++ assert.equal(encodingByDataCoding(0x00), 'GSM7'); ++ assert.equal(encodingByDataCoding(0x01), 'GSM7'); + assert.equal(encodingByDataCoding(0x08), 'UCS2'); + }); + + // 0.4.0 resolved 0x03 to the alias ISO_8859_1, which has no decoder, and silently fell back to + // ASCII — every Latin-1 message came out corrupted. +- test('resolves 0x03 to LATIN1 rather than falling back to ASCII', () => { ++ test('resolves 0x03 to LATIN1 rather than falling back to GSM7', () => { + assert.equal(encodingByDataCoding(0x03), 'LATIN1'); + }); + + test('reads the alphabet bits when a message class is present', () => { +- assert.equal(encodingByDataCoding(0x10), 'ASCII'); +- assert.equal(encodingByDataCoding(0x11), 'ASCII'); ++ assert.equal(encodingByDataCoding(0x10), 'GSM7'); ++ assert.equal(encodingByDataCoding(0x11), 'GSM7'); + assert.equal(encodingByDataCoding(0x18), 'UCS2'); + assert.equal(encodingByDataCoding(0x1A), 'UCS2'); +- assert.equal(encodingByDataCoding(0xF0), 'ASCII'); +- assert.equal(encodingByDataCoding(0xF1), 'ASCII'); ++ assert.equal(encodingByDataCoding(0xF0), 'GSM7'); ++ assert.equal(encodingByDataCoding(0xF1), 'GSM7'); + }); + + // The compressed and automatic-deletion groups put the alphabet where the plain one does. + test('reads them in the compressed and automatic-deletion groups too', () => { +- assert.equal(encodingByDataCoding(0x30), 'ASCII'); ++ assert.equal(encodingByDataCoding(0x30), 'GSM7'); + assert.equal(encodingByDataCoding(0x38), 'UCS2'); + assert.equal(encodingByDataCoding(0x54), 'LATIN1'); + assert.equal(encodingByDataCoding(0x58), 'UCS2'); +@@ -196,10 +196,10 @@ describe('encodingByDataCoding()', () => { + } + }); + +- test('falls back to ASCII for alphabets it has no codec for', () => { +- assert.equal(encodingByDataCoding(0x05), 'ASCII'); +- assert.equal(encodingByDataCoding(0x0E), 'ASCII'); ++ test('falls back to GSM7 for alphabets it has no codec for', () => { ++ assert.equal(encodingByDataCoding(0x05), 'GSM7'); ++ assert.equal(encodingByDataCoding(0x0E), 'GSM7'); + // No class, so nothing says the octet is spelled 03.38 rather than SMPP's own flat table. +- assert.equal(encodingByDataCoding(0x48), 'ASCII'); ++ assert.equal(encodingByDataCoding(0x48), 'GSM7'); + }); + }); +diff --git a/test/interop.test.ts b/test/interop.test.ts +index 2e5d905..e02141a 100644 +--- a/test/interop.test.ts ++++ b/test/interop.test.ts +@@ -249,16 +249,14 @@ describe('a live session against the reference implementation', () => { + }); + + test('a reference client binds to our server and delivers an SMS', async t => { +- const { err: serverErr, server: smpp } = await server({ port: 0 }); ++ let arrived: ((sms: Sms) => void) | undefined; ++ const incoming = new Promise(resolve => { arrived = resolve; }); ++ const { err: serverErr, server: smpp } = await server({ onSms: sms => { arrived?.(sms); }, port: 0 }); + + assert.equal(serverErr, undefined); + assert.ok(smpp); + closeAfter(t, smpp); + +- const incoming = new Promise(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); +- + const refSession = reference.connect({ + url: `smpp://localhost:${String(smpp.port)}`, + }); +diff --git a/test/message-class.test.ts b/test/message-class.test.ts +index e613139..f282e01 100644 +--- a/test/message-class.test.ts ++++ b/test/message-class.test.ts +@@ -27,18 +27,12 @@ type MessagePeer = { + /** A server that answers every message, and a client to write raw submit_sm PDUs at it. */ + async function messagesInto(t: TestContext): Promise { + const received: Sms[] = []; +- const { err, server: smpp } = await server({ port: 0 }); ++ const { err, server: smpp } = await server({ onSms: sms => { received.push(sms); }, port: 0 }); + + assert.equal(err, undefined); + assert.ok(smpp); + closeAfter(t, smpp); + +- smpp.on('session', peer => peer.on('sms', sms => { +- received.push(sms); +- +- return sms.sendResp(); +- })); +- + const connected = await client({ port: smpp.port, reconnect: false }); + + assert.equal(connected.err, undefined); +@@ -216,7 +210,7 @@ describe('sendSms() flash', () => { + const sent = await submitSms(deps, { encoding, from, message: 'Hello world', to }); + + assert.ok(sent.err instanceof Error, JSON.stringify(encoding)); +- assert.match(sent.err.message, /encoding must be ASCII, LATIN1, UCS2/); ++ assert.match(sent.err.message, /encoding must be GSM7, LATIN1, UCS2/); + assert.deepEqual(sent.smsIds, []); + } + +diff --git a/test/message.test.ts b/test/message.test.ts +index 37392a4..4145c6b 100644 +--- a/test/message.test.ts ++++ b/test/message.test.ts +@@ -124,7 +124,7 @@ describe('splitMessage()', () => { + + describe('encodeMessage() and decodeMessage()', () => { + test('picks GSM for GSM-safe text and UCS2 otherwise', () => { +- assert.equal(encodeMessage('Hello').encoding, 'ASCII'); ++ assert.equal(encodeMessage('Hello').encoding, 'GSM7'); + assert.equal(encodeMessage('تست').encoding, 'UCS2'); + }); + +@@ -134,7 +134,7 @@ describe('encodeMessage() and decodeMessage()', () => { + + // 0.4.0 resolved data_coding 0x03 to the alias ISO_8859_1, which has no decoder, so every + // Latin-1 message was silently decoded as ASCII. +- test('decodes Latin-1 rather than falling back to ASCII', () => { ++ test('decodes Latin-1 rather than falling back to GSM7', () => { + assert.equal(decodeMessage(Buffer.from([0xE1, 0xE7, 0xDA]), 0x03).message, 'áçÚ'); + }); + +@@ -165,7 +165,7 @@ describe('encodeMessage() and decodeMessage()', () => { + }); + + describe('the alphabet the encoding helpers are asked for', () => { +- const everyName: EncodingName[] = ['ASCII', 'LATIN1', 'UCS2']; ++ const everyName: EncodingName[] = ['GSM7', 'LATIN1', 'UCS2']; + + test('is one of three, each with a codec, so none of the three helpers can reach an absent one', () => { + assert.deepEqual(Object.keys(encodings).sort(), [...everyName].sort()); +diff --git a/test/readme.test.ts b/test/readme.test.ts +index f2198a5..bb57ef2 100644 +--- a/test/readme.test.ts ++++ b/test/readme.test.ts +@@ -1,6 +1,7 @@ + import assert from 'node:assert/strict'; + import test, { describe } from 'node:test'; + import type { Dlr } from '../src/dlr.ts'; ++import type { OnSms } from '../src/session-options.ts'; + import type { Session } from '../src/session.ts'; + import type { Sms } from '../src/sms.ts'; + import type { SmppLog } from '../src/log.ts'; +@@ -26,21 +27,19 @@ function once(register: (resolve: (value: T) => void) => void): Promise { + } + + /** The README's examples listen on the documented default port, so one server runs at a time. */ +-async function answeringServer(t: TestContext): Promise { +- const { err, server: smpp } = await server(); +- +- assert.equal(err, undefined); +- assert.ok(smpp); +- closeAfter(t, smpp); +- +- smpp.on('session', session => { +- session.on('sms', async sms => { ++async function answeringServer(t: TestContext, onSms?: OnSms): Promise { ++ const { err, server: smpp } = await server({ ++ onSms: onSms ?? (async sms => { + await sms.sendResp(); + + if (sms.dlr) await sms.sendDlr(); +- }); ++ }), + }); + ++ assert.equal(err, undefined); ++ assert.ok(smpp); ++ closeAfter(t, smpp); ++ + return smpp; + } + +@@ -121,10 +120,7 @@ describe('README: Client', () => { + }); + + test('the documented sending options', async t => { +- const smpp = await answeringServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); ++ const incoming = once(resolve => { void answeringServer(t, resolve); }); + const { err, session } = await client(); + if (err) throw err; + +@@ -154,15 +150,20 @@ describe('README: Client', () => { + test('receiving an inbound message on a client session', async t => { + const smpp = await answeringServer(t); + const bound = once(resolve => { smpp.on('session', resolve); }); +- const { err, session } = await client(); ++ const received: string[] = []; ++ const { err, session } = await client({ ++ onSms: sms => { ++ // sms.from, sms.to, sms.message; answered ESME_ROK once this returns ++ received.push(sms.message); ++ }, ++ }); + if (err) throw err; + + closeAfter(t, session); + +- const incoming = once(resolve => { session.on('sms', resolve); }); + const peer = await bound; + +- void peer.send({ ++ const delivered = await peer.send({ + cmdName: 'deliver_sm', + params: { + destination_addr: '46709771337', +@@ -171,29 +172,23 @@ describe('README: Client', () => { + }, + }); + +- const sms = await incoming; +- +- await sms.sendResp(); +- +- assert.equal(sms.message, 'inbound hello'); ++ assert.equal(delivered.pduObj?.cmdStatus, 'ESME_ROK'); ++ assert.deepEqual(received, ['inbound hello']); + }); + }); + + describe('README: Server', () => { + test('the simplest possible server', async t => { +- const { err, server: smpp } = await server(); +- if (err) throw err; +- +- closeAfter(t, smpp); +- + const received: string[] = []; +- +- smpp.on('session', session => { +- session.on('sms', async sms => { ++ const { err, server: smpp } = await server({ ++ onSms: sms => { ++ // sms.from, sms.to, sms.message, sms.dlr, sms.session + received.push(sms.message); +- await sms.sendResp(); +- }); ++ }, + }); ++ if (err) throw err; ++ ++ closeAfter(t, smpp); + + const { err: clientErr, session } = await client(); + +@@ -208,34 +203,27 @@ describe('README: Server', () => { + }); + + test('with authentication and delivery reports', async t => { ++ let answeredOnArrival: boolean | undefined; + const { err, server: smpp } = await server({ + authenticate: ({ password, systemId }) => { + if (systemId !== 'foo' || password !== 'bar') return false; + + return { userData: { userId: 123 } }; + }, +- }); +- if (err) throw err; +- +- closeAfter(t, smpp); +- +- let answeredOnArrival: boolean | undefined; +- +- smpp.on('session', session => { +- session.on('sms', async sms => { +- answeredOnArrival = sms.answeredOnArrival; ++ onSms: async sms => { ++ answeredOnArrival = sms.answered; + +- if (sms.answeredOnArrival) { +- await sms.sendResp(); // multipart: only releases the shutdown drain +- } else { +- await sms.sendResp(); // ESME_ROK with a generated id +- } ++ // no args: ESME_ROK + generated id; or sendResp({ smsId, status: 'ESME_RMSGQFUL' }) ++ await sms.sendResp(); + + if (sms.dlr) { +- await sms.sendDlr(); ++ await sms.sendDlr(); // same as sms.sendDlr('DELIVERED') + } +- }); ++ }, + }); ++ if (err) throw err; ++ ++ closeAfter(t, smpp); + + assert.equal(smpp.port, 2775); + +@@ -264,6 +252,7 @@ describe('README: Server', () => { + + test('refusing a segment at onRequest, before this library would answer it', async t => { + const knownRecipients = new Set(['46709771337']); ++ let messages = 0; + const { err, server: smpp } = await server({ + onRequest: async (session, pduObj) => { + if (!isCommand(pduObj, 'submit_sm') || knownRecipients.has(pduObj.params.destination_addr)) { +@@ -274,15 +263,12 @@ describe('README: Server', () => { + + return true; + }, ++ onSms: () => { messages++; }, + }); + if (err) throw err; + + closeAfter(t, smpp); + +- let messages = 0; +- +- smpp.on('session', bound => bound.on('sms', () => { messages++; })); +- + const connected = await client(); + if (connected.err) throw connected.err; + +diff --git a/test/session-error.test.ts b/test/session-error.test.ts +index af9b3b9..b34942b 100644 +--- a/test/session-error.test.ts ++++ b/test/session-error.test.ts +@@ -38,8 +38,12 @@ async function waitFor(condition: () => boolean, budget = 2000): Promise[0] = {}) { +- const { err, server: smpp } = await server({ port: 0 }); ++async function linked( ++ t: TestContext, ++ options: Parameters[0] = {}, ++ serverOptions: Parameters[0] = {}, ++) { ++ const { err, server: smpp } = await server({ ...serverOptions, port: 0 }); + + assert.equal(err, undefined); + assert.ok(smpp); +@@ -94,19 +98,21 @@ describe('telling a refused PDU from a failed session', () => { + ); + }); + +- test('reports a listener that threw as an error that is no refusal', async t => { +- const { peer, session } = await linked(t, { responseTimeout: 200 }); ++ test('reports a handler that threw as an error that is no refusal', async t => { ++ const { peer, session } = await linked( ++ t, ++ { responseTimeout: 200 }, ++ { onSms: () => { throw new Error('handler exploded'); } }, ++ ); + const failed = once(resolve => { peer.on('sessionError', resolve); }); + +- peer.on('sms', () => { throw new Error('listener exploded'); }); +- +- await session.sendSms({ from: '46701113311', message: 'blows the listener up', to: '46709771337' }); ++ await session.sendSms({ from: '46701113311', message: 'blows the handler up', to: '46709771337' }); + + const reported = await raceWithin(2000, failed); + +- assert.ok(reported, 'the listener that threw never reached the session'); ++ assert.ok(reported, 'the handler that threw never reached the session'); + assert.ok(!(reported instanceof PduRefusedError), 'a session failure is not a refused PDU'); +- assert.equal(reported.message, 'listener exploded'); ++ assert.equal(reported.message, 'handler exploded'); + }); + + test('reports a socket the peer reset as an error that is no refusal', async t => { +diff --git a/test/session-extras.test.ts b/test/session-extras.test.ts +index 6b36279..bac002a 100644 +--- a/test/session-extras.test.ts ++++ b/test/session-extras.test.ts +@@ -4,29 +4,32 @@ import test, { describe } from 'node:test'; + 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 { HandledMessagesOptions } from '../src/handled-messages.ts'; + import type { IncomingRequestsOptions } from '../src/incoming-requests.ts'; +-import type { HeldMessagesOptions, MessageHold } from '../src/held-messages.ts'; ++import type { LifeEffects, LinkState, SessionLifeOptions } from '../src/session-life.ts'; ++import type { OnSms } from '../src/session-options.ts'; + import type { MessageState } from '../src/defs/constants.ts'; + import type { MessageDlr } from '../src/session.ts'; + import type { PduObject, PduObjectInput } from '../src/pdu.ts'; +-import type { Result } from '../src/result.ts'; ++import type { Result, VoidResult } from '../src/result.ts'; + import type { SendSmsResult } from '../src/send-sms.ts'; + import type { SmppLog } from '../src/log.ts'; +-import type { Sms } from '../src/sms.ts'; ++import type { SendRespOptions, Sms, SmsHandlers } from '../src/sms.ts'; + import type { SmppServer } from '../src/server.ts'; + import type { TestContext } from 'node:test'; +-import { HeldMessages } from '../src/held-messages.ts'; ++import { HandledMessages } from '../src/handled-messages.ts'; + import { IncomingRequests, refusedSegmentStatus } from '../src/incoming-requests.ts'; + import { UnansweredError } from '../src/unanswered-error.ts'; + import { createSms } from '../src/sms.ts'; +-import { LinkLife } from '../src/link-life.ts'; ++import { SessionLife } from '../src/session-life.ts'; + import { SendWindow } from '../src/send-window.ts'; + import { Reassembler, decodeSegments } from '../src/reassembly.ts'; + 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, standsInFor } from '../src/session-options.ts'; ++import { defaults } from '../src/defaults.ts'; + import { client } from '../src/client.ts'; + import { closeAfter, closeListenerAfter } from './teardown.ts'; + import { concatOf } from '../src/concat.ts'; +@@ -77,10 +80,70 @@ function once(register: (resolve: (value: T) => void) => void): Promise { + }); + } + ++type Inbox = { ++ /** Answers the message and lets its handler return. */ ++ answer: (sms: Sms, options?: SendRespOptions) => Promise; ++ /** The next message handed over; its handler runs until the test answers or releases it. */ ++ next: () => Promise; ++ onSms: OnSms; ++ release: (sms: Sms) => void; ++}; ++ ++/** An application that hands each message to the test and stays in the handler until told it is done. */ ++function inbox(): Inbox { ++ const queued: Sms[] = []; ++ const waiting: ((sms: Sms) => void)[] = []; ++ const releases = new Map void>(); ++ const release = (sms: Sms): void => { ++ releases.get(sms)?.(); ++ releases.delete(sms); ++ }; ++ ++ return { ++ answer: async (sms, options) => { ++ const answered = await sms.sendResp(options); ++ ++ release(sms); ++ ++ return answered; ++ }, ++ next: () => { ++ const sms = queued.shift(); ++ ++ return sms ? Promise.resolve(sms) : once(resolve => waiting.push(resolve)); ++ }, ++ onSms: sms => new Promise(done => { ++ releases.set(sms, done); ++ ++ const next = waiting.shift(); ++ ++ if (next) next(sms); ++ else queued.push(sms); ++ }), ++ release, ++ }; ++} ++ ++/** A handler that never returns, for a message that must stay held. */ ++const stuck: OnSms = () => new Promise(() => undefined); ++ + function delay(ms: number): Promise { + return new Promise(resolve => { setTimeout(resolve, ms); }); + } + ++/** Polls until the condition holds; false means it never did within the budget. */ ++async function waitFor(condition: () => boolean, budget = 2000): Promise { ++ const deadline = Date.now() + budget; ++ ++ while (!condition()) { ++ if (Date.now() > deadline) return false; ++ ++ await delay(5); ++ } ++ ++ return true; ++} ++ + /** Undefined where the promise never settled, which is an assertion rather than a hung run. */ + function within(ms: number, promise: Promise): Promise { + return Promise.race([promise, delay(ms).then((): undefined => undefined)]); +@@ -101,12 +164,17 @@ function abortAfter( + }); + } + ++/** Answers through the session's own sendReturn, so a test that replaces that sees every answer. */ + function incomingOn(session: Session, options: Partial = {}): IncomingRequests { + return new IncomingRequests({ ++ answer: (pduObj, status, params) => { ++ void session.sendReturn(pduObj, status, params); ++ ++ return {}; ++ }, + 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') }), ++ send: () => Promise.resolve({ err: new Error('never sent') }), + session, + ...options, + }); +@@ -170,10 +238,9 @@ function latch(): Latch { + describe('merged delivery reports', () => { + // 0.4.0 allocated a longSmsDlrs store to do exactly this and then never used it. + test('reports once on a whole multipart message', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); ++ const box = inbox(); ++ const smpp = await startServer(t, { onSms: box.onSms }); ++ const incoming = box.next(); + const { session } = await connect(t, smpp); + + assert.ok(session); +@@ -208,10 +275,9 @@ describe('merged delivery reports', () => { + }); + + test('reports once, on the final receipts, when the peer reports en route first', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); ++ const box = inbox(); ++ const smpp = await startServer(t, { onSms: box.onSms }); ++ const incoming = box.next(); + const { session } = await connect(t, smpp); + + assert.ok(session); +@@ -264,10 +330,9 @@ describe('merged delivery reports', () => { + }); + + test('reports the worst status across the segments', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); ++ const box = inbox(); ++ const smpp = await startServer(t, { onSms: box.onSms }); ++ const incoming = box.next(); + const { session } = await connect(t, smpp); + + assert.ok(session); +@@ -378,10 +443,9 @@ describe('sendSms()', () => { + } + + test('reports a submit_sm the peer refused instead of an empty message id', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); ++ const box = inbox(); ++ const smpp = await startServer(t, { onSms: box.onSms }); ++ const incoming = box.next(); + const { session } = await connect(t, smpp); + + assert.ok(session); +@@ -602,17 +666,8 @@ describe('reconnect', () => { + }); + + test('re-binds after the connection drops, keeping the same session object', async t => { +- const smpp = await startServer(t); + const messages: string[] = []; +- +- // Registered up front so the session created by the reconnect is covered too. +- smpp.on('session', bound => { +- bound.on('sms', sms => { +- messages.push(sms.message); +- void sms.sendResp(); +- }); +- }); +- ++ const smpp = await startServer(t, { onSms: sms => { messages.push(sms.message); } }); + const { err, session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } }); + + assert.equal(err, undefined); +@@ -652,13 +707,9 @@ describe('reconnect', () => { + }); + + test('merges the receipts of a multipart message across a drop', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', bound => bound.on('sms', sms => { +- resolve(sms); +- void sms.sendResp(); +- })); +- }); ++ const box = inbox(); ++ const smpp = await startServer(t, { onSms: box.onSms }); ++ const incoming = box.next(); + const { session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } }); + + assert.ok(session); +@@ -671,6 +722,7 @@ describe('reconnect', () => { + }); + const sms = await incoming; + ++ box.release(sms); + assert.deepEqual(sent.smsIds, [1, 2, 3].map(part => `${sms.smsId}-${String(part)}`)); + + // Answered on the link that then drops, so an SMSC has no reason to ever send it again. +@@ -692,21 +744,23 @@ describe('reconnect', () => { + assert.equal(report.segments.length, 3); + }); + +- test('refuses to answer a message whose link went, held or already answered', async t => { ++ test('refuses to answer a held message whose link went, and has nothing to do for one already answered', async t => { + const smpp = await startServer(t); +- const { session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } }); +- +- assert.ok(session); +- + const arrived: Sms[] = []; +- const both = once(resolve => { +- session.on('sms', sms => { ++ const both = latch(); ++ const { session } = await connect(t, smpp, { ++ onSms: sms => { + arrived.push(sms); + +- if (arrived.length === 2) resolve(true); +- }); ++ if (arrived.length === 2) both.open(); ++ ++ return stuck(sms); ++ }, ++ reconnect: { maxDelay: 100, minDelay: 20 }, + }); + ++ assert.ok(session); ++ + for (const text of ['answered before the drop', 'never answered']) { + void peerOf(smpp).send({ + cmdName: 'deliver_sm', +@@ -718,7 +772,7 @@ describe('reconnect', () => { + }); + } + +- await both; ++ await both.passed; + + const [answered, held] = arrived; + +@@ -737,7 +791,7 @@ describe('reconnect', () => { + // A response is dispatched before `incomingPduObj`, so only the raw event sees one arrive. + peerOf(smpp).on('incomingPdu', () => { taken++; }); + +- assert.match((await answered.sendResp()).err?.message ?? '', /link this message arrived on is gone/); ++ assert.deepEqual(await answered.sendResp(), {}, 'the answer was given on the link that carried the message'); + assert.match((await held.sendResp()).err?.message ?? '', /link this message arrived on is gone/); + + assert.equal((await held.sendDlr('DELIVERED')).err, undefined); +@@ -745,25 +799,28 @@ describe('reconnect', () => { + }); + + test('drops a message whose link went while onRequest was still running', async t => { +- const session = new Session({ sock: new net.Socket() }); +- +- closeAfter(t, session); +- +- const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 100 }); +- const incoming = incomingOn(session, { link, onRequest: async () => { await delay(10); return false; } }); ++ const gone = new net.Socket(); ++ const session = new Session({ sock: gone }); + let messages = 0; + +- session.on('sms', () => { messages++; }); ++ closeAfter(t, session); + ++ const incoming = incomingOn(session, { ++ onRequest: async () => { await delay(10); return false; }, ++ onSms: () => { messages++; }, ++ }); + const handled = incoming.handle(submitPdu(1)); + +- link.drop(); ++ gone.destroy(); + + await handled; + + assert.equal(messages, 0); + +- await incoming.handle(submitPdu(2)); ++ const stayed = new Session({ sock: new net.Socket() }); ++ ++ closeAfter(t, stayed); ++ await incomingOn(stayed, { onSms: () => { messages++; } }).handle(submitPdu(2)); + + assert.equal(messages, 1, 'the harness delivers a message whose link stayed'); + }); +@@ -1148,27 +1205,26 @@ describe('connectTimeout', () => { + + describe('sends across a reconnect', () => { + /** Answers every message after the first, which is left to hold the send window open. */ +- function answerAfterTheFirst(smpp: SmppServer, arrived: string[]): Latch { ++ function answerAfterTheFirst(arrived: string[]): { first: Latch; onSms: OnSms } { + const first = latch(); + +- smpp.on('session', peer => { +- peer.on('sms', async sms => { ++ return { ++ first, ++ onSms: sms => { + arrived.push(sms.message); + +- if (arrived.length === 1) first.open(); +- else await sms.sendResp(); +- }); +- }); ++ if (arrived.length > 1) return undefined; ++ ++ first.open(); + +- return first; ++ return stuck(sms); ++ }, ++ }; + } + + test('holds a send issued while the link is down and puts it on the new link', async t => { +- const smpp = await startServer(t); + const arrived: string[] = []; +- +- smpp.on('session', peer => { peer.on('sms', async sms => { arrived.push(sms.message); await sms.sendResp(); }); }); +- ++ const smpp = await startServer(t, { onSms: sms => { arrived.push(sms.message); } }); + const { session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } }); + + assert.ok(session); +@@ -1187,9 +1243,9 @@ describe('sends across a reconnect', () => { + }); + + test('puts a segment still queued behind a full window on the new link', async t => { +- const smpp = await startServer(t); + const arrived: string[] = []; +- const first = answerAfterTheFirst(smpp, arrived); ++ const { first, onSms } = answerAfterTheFirst(arrived); ++ const smpp = await startServer(t, { onSms }); + const { session } = await connect(t, smpp, { + maxOutstanding: 1, + reconnect: { maxDelay: 100, minDelay: 20 }, +@@ -1228,10 +1284,9 @@ describe('sends across a reconnect', () => { + + return true; + }, ++ onSms: () => undefined, + }); + +- smpp.on('session', peer => { peer.on('sms', async sms => { await sms.sendResp(); }); }); +- + const { session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } }); + + assert.ok(session); +@@ -1258,10 +1313,7 @@ describe('sends across a reconnect', () => { + }); + + test('counts a segment the peer never answered in time as unanswered', async t => { +- const smpp = await startServer(t); +- +- smpp.on('session', peer => { peer.on('sms', () => undefined); }); +- ++ const smpp = await startServer(t, { onSms: stuck }); + const { session } = await connect(t, smpp, { responseTimeout: 200 }); + + assert.ok(session); +@@ -1273,8 +1325,9 @@ describe('sends across a reconnect', () => { + }); + + test('counts a segment aborted after it went out as unanswered', async t => { +- const smpp = await startServer(t); +- const arrived = once(resolve => { smpp.on('session', peer => peer.on('sms', resolve)); }); ++ const box = inbox(); ++ const smpp = await startServer(t, { onSms: box.onSms }); ++ const arrived = box.next(); + const { session } = await connect(t, smpp); + + assert.ok(session); +@@ -1295,8 +1348,9 @@ describe('sends across a reconnect', () => { + }); + + test('reports a segment the link dropped under as unanswered, not as never sent', async t => { +- const smpp = await startServer(t); +- const arrived = once(resolve => { smpp.on('session', peer => peer.on('sms', resolve)); }); ++ const box = inbox(); ++ const smpp = await startServer(t, { onSms: box.onSms }); ++ const arrived = box.next(); + const { session } = await connect(t, smpp, { reconnect: false }); + + assert.ok(session); +@@ -1403,92 +1457,207 @@ describe('sends across a reconnect', () => { + }); + }); + +-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); ++describe('SessionLife', () => { ++ type Recorded = { events: string[]; life: SessionLife; effects: string[] }; + +- link.drop(); +- now = 101; +- +- const held = await waitForLink(); ++ function lifeWith(reconnect?: Partial & { onEvent?: (event: string, life: SessionLife) => void }): Recorded { ++ const effects: string[] = []; ++ const events: string[] = []; ++ const record = (name: keyof LifeEffects) => () => { effects.push(name); }; ++ const recorded: Recorded = { ++ effects, ++ events, ++ life: new SessionLife({ ++ effects: { ++ attach: record('attach'), ++ emit: event => { ++ events.push(event); ++ reconnect?.onEvent?.(event, recorded.life); ++ }, ++ linkDown: record('linkDown'), ++ linkUp: record('linkUp'), ++ over: record('over'), ++ }, ++ log: silentLog, ++ now: reconnect?.now, ++ reconnect: reconnect?.reconnect, ++ }), ++ }; + +- assert.match(held.err?.message ?? '', /did not come back in time/); +- }); ++ return recorded; ++ } + +- test('holds on a timer that keeps the process alive', async () => { +- const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 10_000 }); +- const timers = (): number => process.getActiveResourcesInfo().filter(name => name === 'Timeout').length; ++ const noLink = { connect: () => Promise.resolve({ err: new Error('no socket') }), rebind: () => Promise.resolve({}) }; + +- link.drop(); ++ test('walks the states the table draws, and a transition from a state that has none is ignored', () => { ++ const { effects, events, life } = lifeWith(); ++ const path: LinkState[] = [life.state]; + +- const before = timers(); +- const held = link.hold(undefined)(); ++ life.linkLost(); ++ path.push(life.state); ++ assert.deepEqual(path, ['connected', 'ended'], 'no reconnect policy, so a lost link ends the session'); ++ assert.deepEqual(effects, ['linkDown', 'over']); ++ assert.deepEqual(events, ['close']); + +- assert.equal(timers(), before + 1, 'an unref\'d timer is not counted here, which is the point'); ++ life.bound(); ++ life.linkLost(); ++ assert.equal(life.state, 'ended', 'ended is final'); ++ assert.deepEqual(events, ['close']); ++ }); + +- link.open(); ++ test('binds, drains and ends in that order, and only a bound link is drained', () => { ++ const { effects, events, life } = lifeWith(); + +- assert.deepEqual(await held, {}); ++ assert.equal(life.closing(), false, 'nothing bound, nothing to drain'); ++ life.bound(); ++ assert.equal(life.state, 'bound'); ++ assert.deepEqual(effects, ['linkUp']); ++ assert.deepEqual(events, [], 'the first bind is not a reconnect'); ++ assert.equal(life.closing(), true); ++ assert.equal(life.state, 'closing'); ++ assert.equal(life.carries(), true, 'a receipt still goes out during the drain'); ++ life.end(); ++ assert.equal(life.state, 'ended'); ++ assert.deepEqual(events, ['close']); + }); + +- // addEventListener never fires for a signal that already aborted, so it would wait out the timeout. +- test('gives up at once on a signal that was already aborted', async () => { +- const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 100 }); ++ test('goes down and comes back up on a reconnect policy, reporting each step once', async t => { ++ const opened: net.Socket[] = []; ++ const { effects, events, life } = lifeWith({ ++ reconnect: { ++ connect: () => { ++ const sock = new net.Socket(); + +- link.drop(); ++ opened.push(sock); + +- const held = await link.hold(AbortSignal.abort())(); ++ return Promise.resolve({ sock }); ++ }, ++ maxDelay: 20, ++ minDelay: 5, ++ rebind: () => { ++ life.bound(); + +- assert.match(held.err?.message ?? '', /Aborted while waiting for a link/); ++ return Promise.resolve({}); ++ }, ++ }, ++ }); ++ ++ t.after(() => { life.end(); for (const sock of opened) sock.destroy(); }); ++ life.bound(); ++ life.linkLost(); ++ assert.equal(life.state, 'down'); ++ assert.deepEqual(events, ['disconnected']); ++ assert.ok(await waitFor(() => life.state === 'bound')); ++ assert.deepEqual(events, ['disconnected', 'reconnected']); ++ assert.deepEqual(effects, ['linkUp', 'linkDown', 'attach', 'linkUp']); ++ life.linkLost(); ++ assert.equal(life.state, 'down', 'a second drop is retried like the first'); + }); + +- test('awaits the next link only while down with one on its way', () => { +- const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 100 }); ++ // Every event is emitted after its state and effects are in place, so this needs no guard. ++ test('lets a disconnected listener end the session without a retry ever running', async t => { ++ let attempts = 0; ++ const { events, life } = lifeWith({ ++ onEvent: (event, current) => { if (event === 'disconnected') current.end(); }, ++ reconnect: { ...noLink, connect: () => { attempts++; return noLink.connect(); }, maxDelay: 10, minDelay: 1 }, ++ }); + +- assert.equal(link.awaitsNextLink(), false, 'up'); +- link.drop(); +- assert.equal(link.awaitsNextLink(), true, 'down, returning'); +- link.attach(); +- assert.equal(link.awaitsNextLink(), true, 'attached, not yet bound'); +- link.open(); +- assert.equal(link.awaitsNextLink(), false, 'reopened'); +- link.drop(); +- link.stop(); +- assert.equal(link.awaitsNextLink(), false, 'down, stopped'); +- assert.match(link.refusal()?.message ?? '', /closed/, 'stopped while down'); +- link.end(); +- assert.equal(link.awaitsNextLink(), false, 'ended'); ++ t.after(() => { life.end(); }); ++ life.bound(); ++ life.linkLost(); ++ assert.equal(life.state, 'ended'); ++ assert.deepEqual(events, ['disconnected', 'close']); ++ await delay(30); ++ assert.equal(attempts, 0, 'the retry timer went with the state'); + }); + +- test('drops an attached link once, counts each drop, and names the event it warrants', () => { +- const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 100 }); +- const generation = link.generation(); ++ test('keeps backing off when every link dies as soon as it comes up', async t => { ++ const clock = { now: 0 }; ++ const delays: number[] = []; ++ const log: SmppLog = { ++ ...silentLog, ++ info: (msg, metadata) => { ++ if (msg === 'reconnect - retrying') delays.push(Number(metadata?.delay)); ++ }, ++ }; ++ const life = new SessionLife({ ++ effects: { attach: () => undefined, emit: () => undefined, linkDown: () => undefined, linkUp: () => undefined, over: () => undefined }, ++ log, ++ now: () => clock.now, ++ reconnect: { ++ connect: () => Promise.resolve({ sock: new net.Socket() }), ++ maxDelay: 80, ++ minDelay: 10, ++ rebind: () => { life.bound(); return Promise.resolve({}); }, ++ }, ++ }); ++ ++ t.after(() => { life.end(); }); ++ life.bound(); ++ ++ for (let died = 0; died < 4; died++) { ++ life.linkLost(); ++ assert.ok(await waitFor(() => life.state === 'bound')); ++ } ++ ++ assert.deepEqual(delays, [10, 20, 40, 80]); + +- assert.equal(link.drop(), 'disconnected'); +- assert.equal(link.drop(), undefined, '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.equal(link.generation(), generation + 2); +- assert.equal(new LinkLife({ log: silentLog, reconnects: false, timeout: 100 }).drop(), 'close'); ++ // A link that outlasted the longest wait earned a fresh start. ++ clock.now += 80; ++ life.linkLost(); ++ assert.ok(await waitFor(() => delays.length === 5)); ++ assert.deepEqual(delays, [10, 20, 40, 80, 10]); + }); + +- test('releases a held request with the reason once the link ends', async () => { +- const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 0 }); ++ test('starts only one reconnect attempt at a time', async t => { ++ let attempts = 0; ++ let finish: (() => void) | undefined; ++ const { life } = lifeWith({ ++ reconnect: { ++ connect: () => { ++ attempts++; ++ ++ return new Promise(resolve => { ++ finish = () => { resolve({ err: new Error('no socket') }); }; ++ }); ++ }, ++ maxDelay: 5, ++ minDelay: 1, ++ rebind: () => Promise.resolve({}), ++ }, ++ }); + +- link.drop(); ++ t.after(() => { life.end(); finish?.(); }); ++ life.bound(); ++ life.linkLost(); ++ assert.ok(await waitFor(() => attempts === 1)); + +- const held = link.hold(undefined)(); ++ // A second drop landing while the first attempt is still inside connect(). ++ life.linkLost(); ++ await delay(30); ++ assert.equal(attempts, 1); ++ }); + +- link.end(); ++ test('keeps the reconnect loop alive when connect throws', async t => { ++ let attempts = 0; ++ const { life } = lifeWith({ ++ reconnect: { ++ connect: () => { ++ attempts++; + +- assert.match((await held).err?.message ?? '', /Session is closed/); +- link.attach(); +- assert.equal(link.isAttached(), false, 'ended is final'); +- assert.equal(link.end(), false); ++ throw new Error('connect exploded'); ++ }, ++ maxDelay: 10, ++ minDelay: 1, ++ rebind: () => Promise.resolve({}), ++ }, ++ }); ++ ++ t.after(() => { life.end(); }); ++ life.bound(); ++ life.linkLost(); ++ ++ assert.ok(await waitFor(() => attempts >= 2), 'a throwing connect should be retried, not left for the process to die on'); + }); + }); + +@@ -1537,77 +1706,81 @@ describe('SendWindow', () => { + }); + + // Goal 4: an application that answers nothing must not grow this for the life of the link. +-describe('held message bounds', () => { +- function message(seqNr: number): PduObject[] { +- return [submitPdu(seqNr)]; +- } ++describe('handled message bounds', () => { ++ type Handled = { handled: HandledMessages; release: (sms: Sms) => void; reported: Error[]; running: Sms[] }; + +- function offer(held: HeldMessages, seqNr: number): MessageHold { +- const hold = held.offer(message(seqNr)); ++ /** Handlers that stay in the message until released, so an offer is counted until the test says otherwise. */ ++ function handledOn( ++ t: TestContext, ++ options: Pick, ++ onSms?: OnSms, ++ ): Handled { ++ const releases = new Map void>(); ++ const reported: Error[] = []; ++ const running: Sms[] = []; ++ const handled = new HandledMessages({ ++ ...options, ++ log: silentLog, ++ onSms: onSms ?? (sms => new Promise(done => { ++ running.push(sms); ++ releases.set(sms, done); ++ })), ++ report: err => { reported.push(err); }, ++ }); + +- assert.ok(hold); ++ t.after(() => { handled.clear(); }); + +- return hold; ++ return { handled, release: sms => releases.get(sms)?.(), reported, running }; + } + +- /** Offers to a session with a listener, so an offer is held rather than released as untaken. */ +- function heldOn( +- t: TestContext, +- options: Pick, +- ): HeldMessages { ++ const answers: SmsHandlers = { ++ answer: () => ({}), ++ send: () => Promise.resolve({ err: new Error('never sent') }), ++ }; ++ ++ function offer(handled: HandledMessages, seqNr: number, t: TestContext): Sms { + const session = new Session({ sock: new net.Socket() }); + + closeAfter(t, session); +- session.on('sms', () => undefined); + +- 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 handled.offer({ link: session.sock, pduObjs: [submitPdu(seqNr)], session }, answers, 'ESME_RTHROTTLED'); + } + +- test('is full at its count, and a re-used sequence number replaces rather than adding', t => { +- const held = heldOn(t, { max: 2, maxOctets: 1_000_000, timeout: 10_000 }); +- const first = offer(held, 1); +- const replaced = offer(held, 2); ++ test('is full at its count', t => { ++ const { handled } = handledOn(t, { max: 2, maxOctets: 1_000_000, timeout: 10_000 }); + +- offer(held, 2); ++ offer(handled, 1, t); ++ assert.equal(handled.refuses(), false); ++ offer(handled, 2, t); + +- 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); +- +- held.clear(); ++ assert.equal(handled.size, 2); ++ assert.equal(handled.octets, 2 * 1026, 'submitPdu() holds 1026 octets by the maxOctets charge'); ++ assert.equal(handled.refuses(), true); + }); + +- // 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); ++ const { handled, release } = handledOn(t, { max: 10, maxOctets: 2000, now: () => now, timeout: 10_000 }); ++ const returned = offer(handled, 1, t); + +- assert.equal(held.full(), false); +- offer(held, 2); +- assert.equal(held.full(), true); ++ assert.equal(handled.refuses(), false); ++ offer(handled, 2, t); ++ assert.equal(handled.refuses(), true); + +- answered.release(); +- assert.equal(held.full(), false, 'after a release'); +- offer(held, 3); ++ release(returned); ++ await new Promise(resolve => { setImmediate(resolve); }); ++ assert.equal(handled.refuses(), false, 'after the handler returned'); ++ offer(handled, 3, t); + + now = 20_000; +- held.sweep(); ++ handled.sweep(); + now = 0; +- assert.equal(held.full(), false, 'after a sweep'); +- offer(held, 4); +- offer(held, 5); ++ assert.equal(handled.refuses(), false, 'after a sweep'); ++ offer(handled, 4, t); ++ offer(handled, 5, t); + +- held.clear(); +- assert.equal(held.full(), false, 'after a clear'); ++ handled.clear(); ++ assert.equal(handled.refuses(), false, 'after a clear'); + }); + + // Dropping one the application still holds frees nothing, and the drain stops waiting for it. +@@ -1617,44 +1790,50 @@ describe('held message bounds', () => { + closeAfter(t, session); + + const warnings: string[] = []; +- const incoming = incomingOn(session, { log: { ...silentLog, warn: message => { warnings.push(message); } } }); +- const answers: (ErrorName | undefined)[] = []; ++ const releases: (() => void)[] = []; + const received: Sms[] = []; ++ const answers: (ErrorName | undefined)[] = []; ++ const incoming = incomingOn(session, { ++ log: { ...silentLog, warn: message => { warnings.push(message); } }, ++ onSms: sms => new Promise(done => { ++ received.push(sms); ++ releases.push(done); ++ }), ++ }); + + session.sendReturn = (_pdu, status) => { + answers.push(status); + + return Promise.resolve({}); + }; +- session.on('sms', sms => { received.push(sms); }); + +- for (let seqNr = 1; seqNr <= defaults.maxHeldMessages; seqNr++) { ++ for (let seqNr = 1; seqNr <= defaults.session.maxHandledMessages; seqNr++) { + await incoming.handle(submitPdu(seqNr)); + } + +- assert.equal(received.length, defaults.maxHeldMessages); ++ assert.equal(received.length, defaults.session.maxHandledMessages); + +- await incoming.handle(submitPdu(defaults.maxHeldMessages + 1)); ++ await incoming.handle(submitPdu(defaults.session.maxHandledMessages + 1)); + await incoming.handle(segment(7, 1, 2)); + +- assert.equal(received.length, defaults.maxHeldMessages); ++ assert.equal(received.length, defaults.session.maxHandledMessages); + assert.deepEqual(answers, ['ESME_RTHROTTLED', 'ESME_RTHROTTLED']); + assert.equal(warnings.length, 1, 'reaching the bound warns once, not per refusal'); + + // The refused first segment joined no group, so the second one is taken and completes nothing. +- await received[0]?.sendResp(); ++ releases[0]?.(); + await new Promise(resolve => { setImmediate(resolve); }); + await incoming.handle(segment(7, 2, 2)); + + assert.equal(answers.at(-1), 'ESME_ROK'); +- assert.equal(received.length, defaults.maxHeldMessages); ++ assert.equal(received.length, defaults.session.maxHandledMessages); + + // A peer keeping its window full crosses the bound on every answer, and that is still one warning. +- await received[1]?.sendResp(); ++ releases[1]?.(); + await new Promise(resolve => { setImmediate(resolve); }); +- await incoming.handle(submitPdu(defaults.maxHeldMessages + 2)); +- await incoming.handle(submitPdu(defaults.maxHeldMessages + 3)); +- await incoming.handle(submitPdu(defaults.maxHeldMessages + 4)); ++ await incoming.handle(submitPdu(defaults.session.maxHandledMessages + 2)); ++ await incoming.handle(submitPdu(defaults.session.maxHandledMessages + 3)); ++ await incoming.handle(submitPdu(defaults.session.maxHandledMessages + 4)); + + assert.equal(answers.at(-1), 'ESME_RTHROTTLED'); + assert.equal(warnings.length, 1); +@@ -1666,12 +1845,11 @@ describe('held message bounds', () => { + + closeAfter(t, session); + +- const incoming = incomingOn(session); ++ let received: Sms | undefined; ++ const incoming = incomingOn(session, { onSms: sms => { received = sms; } }); + const chunk = Buffer.alloc(64 * 1024); + const carried = submitPdu(1); +- let received: Sms | undefined; + +- session.on('sms', sms => { received = sms; }); + await incoming.handle({ ...carried, params: { ...carried.params, short_message: chunk.subarray(16, 20) } }); + + const retained = received?.pduObjs[0]?.params.short_message; +@@ -1681,59 +1859,136 @@ describe('held message bounds', () => { + incoming.clear(); + }); + +- test('gives up on a message the application never answers', t => { ++ test('stops counting a handler still running past its deadline', t => { + let now = 0; +- const held = heldOn(t, { max: 10, maxOctets: 1_000_000, now: () => now, timeout: 60 }); ++ const { handled } = handledOn(t, { max: 10, maxOctets: 1_000_000, now: () => now, timeout: 60 }); + +- offer(held, 1); ++ offer(handled, 1, t); + now = 61; + + // The next message sweeps the one that expired, so only the new one is still waited for. +- offer(held, 2); ++ offer(handled, 2, t); + +- assert.equal(held.size, 1); +- +- held.clear(); ++ assert.equal(handled.size, 1); + }); + + // Without this the drain sits out its whole budget before returning what a sweep already settled. +- test('wakes a waiting drain when the last message expires', async t => { ++ test('wakes a waiting drain when the last handler passes its deadline', async t => { + let now = 0; +- const held = heldOn(t, { max: 10, maxOctets: 1_000_000, now: () => now, timeout: 60 }); ++ const { handled } = handledOn(t, { max: 10, maxOctets: 1_000_000, now: () => now, timeout: 60 }); + +- offer(held, 1); ++ offer(handled, 1, t); + +- const waiting = held.idle(1000, undefined); ++ const waiting = handled.idle(1000, undefined); + + now = 61; +- held.sweep(); ++ handled.sweep(); + + assert.equal(await waiting, 0); + }); ++ ++ test('answers ESME_ROK for a handler that returned without answering, and nothing for one that did', async t => { ++ const statuses: ErrorName[] = []; ++ const { handled } = handledOn(t, { max: 10, maxOctets: 1_000_000, timeout: 10_000 }, sms => { ++ if (sms.pduObjs[0]?.seqNr === 2) return sms.sendResp({ smsId: '0199f1a2-3b4c-7d5e-8f60-71a2b3c4d5e6' }); ++ ++ return undefined; ++ }); ++ const session = new Session({ sock: new net.Socket() }); ++ ++ closeAfter(t, session); ++ ++ const recording: SmsHandlers = { ...answers, answer: (_pduObj, status) => { statuses.push(status); return {}; } }; ++ const returned = handled.offer({ link: session.sock, pduObjs: [submitPdu(1)], session }, recording, 'ESME_RTHROTTLED'); ++ const answered = handled.offer({ link: session.sock, pduObjs: [submitPdu(2)], session }, recording, 'ESME_RTHROTTLED'); ++ ++ assert.equal(await handled.idle(1000, undefined), 0); ++ assert.deepEqual(statuses, ['ESME_ROK', 'ESME_ROK']); ++ assert.equal(returned.answered, true); ++ assert.match(returned.smsId, /^[0-9a-f]{8}-/, 'a generated id'); ++ assert.equal(answered.smsId, '0199f1a2-3b4c-7d5e-8f60-71a2b3c4d5e6'); ++ }); ++ ++ test('refuses with the retry status for a handler that failed before answering, and reports it', async t => { ++ const statuses: ErrorName[] = []; ++ const { handled, reported } = handledOn(t, { max: 10, maxOctets: 1_000_000, timeout: 10_000 }, () => { ++ throw new Error('the handler exploded'); ++ }); ++ const session = new Session({ sock: new net.Socket() }); ++ ++ closeAfter(t, session); ++ handled.offer( ++ { link: session.sock, pduObjs: [submitPdu(1)], session }, ++ { ...answers, answer: (_pduObj, status) => { statuses.push(status); return {}; } }, ++ 'ESME_RX_T_APPN', ++ ); ++ ++ assert.equal(await handled.idle(1000, undefined), 0); ++ assert.deepEqual(statuses, ['ESME_RX_T_APPN']); ++ assert.deepEqual(reported.map(err => err.message), ['the handler exploded']); ++ }); ++ ++ test('refuses a message no handler takes, and reports it', async t => { ++ const statuses: ErrorName[] = []; ++ const reported: Error[] = []; ++ const handled = new HandledMessages({ ++ log: silentLog, ++ max: 10, ++ maxOctets: 1_000_000, ++ onSms: undefined, ++ report: err => { reported.push(err); }, ++ timeout: 10_000, ++ }); ++ const session = new Session({ sock: new net.Socket() }); ++ ++ closeAfter(t, session); ++ handled.offer( ++ { link: session.sock, pduObjs: [submitPdu(1)], session }, ++ { ...answers, answer: (_pduObj, status) => { statuses.push(status); return {}; } }, ++ 'ESME_RTHROTTLED', ++ ); ++ ++ assert.equal(await handled.idle(1000, undefined), 0); ++ assert.deepEqual(statuses, ['ESME_RTHROTTLED']); ++ assert.match(reported[0]?.message ?? '', /No onSms handler/); ++ }); + }); + + describe('sendResp()', () => { ++ const handlers: SmsHandlers = { ++ answer: () => ({ err: new Error('Socket is closed') }), ++ send: () => Promise.resolve({ err: new Error('never sent') }), ++ }; ++ + // A response the wire never carried leaves the peer owed one, so nothing may count it answered. + test('does not count a response that never reached the wire as an answer', async t => { + const session = new Session({ sock: new net.Socket() }); + + closeAfter(t, session); + +- let answered = 0; ++ const sms = createSms({ link: session.sock, pduObjs: [submitPdu(1)], session }, handlers); + +- session.sendReturn = () => Promise.resolve({ err: new Error('Socket is closed') }); ++ assert.match((await sms.sendResp()).err?.message ?? '', /Socket is closed/); ++ assert.equal(sms.answered, false); ++ }); + +- const sms = createSms({ +- pduObjs: [submitPdu(1)], +- session, +- }, { +- answered: () => { answered++; }, +- lostLink: () => false, +- send: () => Promise.resolve({ err: new Error('never sent') }), ++ test('answers once, and refuses to change an answer already given', async t => { ++ const session = new Session({ sock: new net.Socket() }); ++ const statuses: ErrorName[] = []; ++ ++ closeAfter(t, session); ++ ++ const sms = createSms({ link: session.sock, pduObjs: [submitPdu(1)], session }, { ++ ...handlers, ++ answer: (_pduObj, status) => { statuses.push(status); return {}; }, + }); + +- assert.match((await sms.sendResp()).err?.message ?? '', /Socket is closed/); +- assert.equal(answered, 0); ++ assert.deepEqual(await sms.sendResp({ smsId: '0199f1b0-0c1d-7e2f-9a3b-4c5d6e7f8091', status: 'ESME_RMSGQFUL' }), {}); ++ assert.equal(sms.answered, true); ++ assert.deepEqual(await sms.sendResp({ status: 'ESME_RMSGQFUL' }), {}, 'the same answer again is nothing to do'); ++ assert.match((await sms.sendResp()).err?.message ?? '', /already answered ESME_RMSGQFUL/); ++ assert.match((await sms.sendResp({ smsId: 'another' })).err?.message ?? '', /already answered under id/); ++ assert.deepEqual(statuses, ['ESME_RMSGQFUL']); + }); + }); + +@@ -1746,11 +2001,11 @@ describe('sendDlr()', () => { + + let call = 0; + const sms = createSms({ ++ link: session.sock, + pduObjs: [submitPdu(1), submitPdu(2), submitPdu(3)], + session, + }, { +- answered: () => undefined, +- lostLink: () => false, ++ answer: () => ({}), + send: () => { + call++; + +@@ -2351,7 +2606,7 @@ describe('a peer that sends the next segment only once the last one is answered' + params: submitSmParams( + { from: '46701113311', message: text, to: '46709771337' }, + segment, +- { encoding: 'ASCII', multipart: true }, ++ { encoding: 'GSM7', multipart: true }, + ), + }); + +@@ -2372,14 +2627,10 @@ describe('a peer that sends the next segment only once the last one is answered' + } + + test('gets every segment answered as it arrives, and the application one whole message', async t => { +- const smpp = await startServer(t); ++ const box = inbox(); + const messages: Sms[] = []; +- const incoming = once(resolve => { +- smpp.on('session', bound => bound.on('sms', sms => { +- messages.push(sms); +- resolve(sms); +- })); +- }); ++ const smpp = await startServer(t, { onSms: sms => { messages.push(sms); return box.onSms(sms); } }); ++ const incoming = box.next(); + const { session } = await connect(t, smpp, { responseTimeout: 1000 }); + + assert.ok(session); +@@ -2390,7 +2641,7 @@ describe('a peer that sends the next segment only once the last one is answered' + assert.equal(sms.message, text); + assert.equal(sms.pduObjs.length, answers.length); + assert.equal(messages.length, 1, 'the application sees one message, not one per segment'); +- assert.equal(sms.answeredOnArrival, true); ++ assert.equal(sms.answered, true); + assert.deepEqual(answers.map(answer => answer.cmdStatus), answers.map(() => 'ESME_ROK')); + assert.deepEqual( + answers.map(answer => paramText(answer.params.message_id)), +@@ -2401,10 +2652,9 @@ describe('a peer that sends the next segment only once the last one is answered' + + // The documented single-segment contract, which the segment-by-segment answer must not touch. + test('answers a single-segment message only once the application does, with the id it chose', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', bound => bound.on('sms', resolve)); +- }); ++ const box = inbox(); ++ const smpp = await startServer(t, { onSms: box.onSms }); ++ const incoming = box.next(); + const { session } = await connect(t, smpp); + + assert.ok(session); +@@ -2420,7 +2670,7 @@ describe('a peer that sends the next segment only once the last one is answered' + const sms = await incoming; + + assert.equal(await within(150, submitted), undefined, 'nothing may answer for the application'); +- assert.equal(sms.answeredOnArrival, false); ++ assert.equal(sms.answered, false); + assert.deepEqual(await sms.sendResp({ smsId: '0199e0e9-4a3e-7c62-9a4b-1f0c5d7e8a21' }), {}); + + const answered = await submitted; +@@ -2433,10 +2683,9 @@ describe('a peer that sends the next segment only once the last one is answered' + }); + + test('refuses an id and a refusing status for segments already on the wire', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', bound => bound.on('sms', resolve)); +- }); ++ const box = inbox(); ++ const smpp = await startServer(t, { onSms: box.onSms }); ++ const incoming = box.next(); + const { session } = await connect(t, smpp, { responseTimeout: 1000 }); + + assert.ok(session); +@@ -2447,20 +2696,17 @@ describe('a peer that sends the next segment only once the last one is answered' + const named = await sms.sendResp({ smsId: '0199e0ea-1f3d-7ab4-8c21-6d4e5f0a9b73' }); + const refused = await sms.sendResp({ status: 'ESME_RMSGQFUL' }); + +- assert.match(named.err?.message ?? '', /fixed when its first segment arrived/); ++ assert.match(named.err?.message ?? '', /already answered under id/); + assert.match(refused.err?.message ?? '', /onRequest/); + assert.deepEqual(await sms.sendResp({ status: 'ESME_ROK' }), {}); +- assert.equal(sms.answeredOnArrival, true); ++ assert.equal(sms.answered, true); + }); + + test('reports a half-arrived message it has already answered, and holds nothing after', async t => { +- const smpp = await startServer(t, { reassemblyTimeout: 60 }); + const messages: Sms[] = []; ++ const smpp = await startServer(t, { onSms: sms => { messages.push(sms); }, reassemblyTimeout: 60 }); + const lost = once(resolve => { +- smpp.on('session', bound => { +- bound.on('sessionError', resolve); +- bound.on('sms', sms => { messages.push(sms); }); +- }); ++ smpp.on('session', bound => { bound.on('sessionError', resolve); }); + }); + const { session } = await connect(t, smpp, { responseTimeout: 1000 }); + +@@ -2500,7 +2746,7 @@ describe('a peer that sends the next segment only once the last one is answered' + params: submitSmParams( + { from: '46701113311', message: text, to: '46709771337' }, + first, +- { encoding: 'ASCII', multipart: true }, ++ { encoding: 'GSM7', multipart: true }, + ), + }); + +@@ -2512,10 +2758,9 @@ describe('a peer that sends the next segment only once the last one is answered' + }); + + test('close() still waits for a concatenated message the application has not answered', async t => { +- const smpp = await startServer(t, { shutdownTimeout: 50 }); +- const incoming = once(resolve => { +- smpp.on('session', bound => bound.on('sms', resolve)); +- }); ++ const box = inbox(); ++ const smpp = await startServer(t, { onSms: box.onSms, shutdownTimeout: 50 }); ++ const incoming = box.next(); + const { session } = await connect(t, smpp, { responseTimeout: 1000 }); + + assert.ok(session); +@@ -2526,15 +2771,14 @@ describe('a peer that sends the next segment only once the last one is answered' + const closed = await peerOf(smpp).close(); + + assert.ok(closed.err instanceof Error); +- assert.match(closed.err.message, /1 message\(s\) unanswered/); ++ assert.match(closed.err.message, /1 message\(s\) still being handled/); + }); + +- // pduObjs.length is 1 either way here, so answeredOnArrival is the only thing that can say. ++ // pduObjs.length is 1 either way here, so answered is the only thing that can say. + test('marks a one-part concatenated message answered, as its segment count cannot', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', bound => bound.on('sms', resolve)); +- }); ++ const box = inbox(); ++ const smpp = await startServer(t, { onSms: box.onSms }); ++ const incoming = box.next(); + const { session } = await connect(t, smpp, { responseTimeout: 1000 }); + + assert.ok(session); +@@ -2558,17 +2802,14 @@ describe('a peer that sends the next segment only once the last one is answered' + assert.equal(answered.pduObj.cmdStatus, 'ESME_ROK'); + assert.equal(paramText(answered.pduObj.params.message_id), sms.smsId); + assert.equal(sms.pduObjs.length, 1); +- assert.equal(sms.answeredOnArrival, true); ++ assert.equal(sms.answered, true); + assert.deepEqual(await sms.sendResp(), {}); + }); + + // esm_class said there was a UDH, and there is no group its concatenation fields can join. + test('answers a segment whose UDH cannot be honoured rather than leaving the peer waiting', async t => { +- const smpp = await startServer(t); + const messages: Sms[] = []; +- +- smpp.on('session', bound => bound.on('sms', sms => { messages.push(sms); })); +- ++ const smpp = await startServer(t, { onSms: sms => { messages.push(sms); } }); + const { session } = await connect(t, smpp, { responseTimeout: 1000 }); + + assert.ok(session); +@@ -2594,11 +2835,8 @@ describe('a peer that sends the next segment only once the last one is answered' + + // Its esm_class is 0x00 and correct, so the refusal names the optional parameters instead. + test('refuses a sar_* segment the TLVs number impossibly by naming those TLVs', async t => { +- const smpp = await startServer(t); + const messages: Sms[] = []; +- +- smpp.on('session', bound => bound.on('sms', sms => { messages.push(sms); })); +- ++ const smpp = await startServer(t, { onSms: sms => { messages.push(sms); } }); + const { session } = await connect(t, smpp, { responseTimeout: 1000 }); + + assert.ok(session); +@@ -2662,13 +2900,10 @@ describe('AbortSignal on a send', () => { + t: TestContext, + options: Parameters[0] = {}, + ): Promise { +- const smpp = await startServer(t); ++ const smpp = await startServer(t, { onSms: stuck }); + const onWire = once(resolve => { + smpp.on('session', bound => bound.on('incomingPduObj', resolve)); + }); +- +- smpp.on('session', bound => bound.on('sms', () => undefined)); +- + const { session } = await connect(t, smpp, { maxOutstanding: 1, ...options }); + + assert.ok(session); +@@ -2716,18 +2951,18 @@ describe('AbortSignal on a send', () => { + }); + + test('leaves the freed slot to the next send rather than to the waiter that gave up', async t => { +- const smpp = await startServer(t); +- const holding = once(resolve => { smpp.on('session', bound => bound.on('sms', resolve)); }); ++ const box = inbox(); + let firstTaken = false; +- +- smpp.on('session', bound => { +- bound.on('sms', async sms => { +- if (firstTaken) await sms.sendResp(); ++ const smpp = await startServer(t, { ++ onSms: sms => { ++ if (firstTaken) return undefined; + + firstTaken = true; +- }); +- }); + ++ return box.onSms(sms); ++ }, ++ }); ++ const holding = box.next(); + const { session } = await connect(t, smpp, { maxOutstanding: 1, responseTimeout: 10_000 }); + + assert.ok(session); +@@ -2748,7 +2983,7 @@ describe('AbortSignal on a send', () => { + assert.ok(gaveUp, 'the waiter that gave up must settle before the slot it left is freed'); + // Any other error means it never reached the queue, so there was no waiter to strand. + assert.match(gaveUp.err?.message ?? '', /Aborted while waiting for a send window slot/); +- await held.sendResp(); ++ await box.answer(held); + + const following = await within(1000, session.sendSms({ + from: '46701113311', +@@ -2768,17 +3003,15 @@ describe('graceful shutdown', () => { + serverOptions: Parameters[0] = {}, + message = 'answer me', + ) { +- const smpp = await startServer(t, serverOptions); +- const incoming = once(resolve => { +- smpp.on('session', bound => bound.on('sms', resolve)); +- }); ++ const box = inbox(); ++ const smpp = await startServer(t, { onSms: box.onSms, ...serverOptions }); + const { session } = await connect(t, smpp, options); + + assert.ok(session); + + const sent = session.sendSms({ from: '46701113311', message, to: '46709771337' }); + +- return { sent, session, sms: await incoming, smpp }; ++ return { box, sent, session, sms: await box.next(), smpp }; + } + + test('close() waits out a submit already on the wire and refuses new ones', async t => { +@@ -2824,43 +3057,42 @@ describe('graceful shutdown', () => { + assert.deepEqual(await unbound, {}); + }); + +- test('close() waits for a message the application has not answered yet', async t => { +- const { sent, smpp, sms } = await submitInFlight(t); ++ test('close() waits for a handler still running, and ends once it returns', async t => { ++ const { box, sent, smpp, sms } = await submitInFlight(t); + const closing = peerOf(smpp).close(); + + await delay(50); +- await sms.sendResp({ smsId: 'answered-during-the-inbound-drain' }); ++ await box.answer(sms, { smsId: 'answered-during-the-inbound-drain' }); + + assert.deepEqual(await closing, {}); + assert.deepEqual((await sent).smsIds, ['answered-during-the-inbound-drain']); + }); + +- test('gives up on a message the application never answers', async t => { ++ test('gives up on a handler that never returns', async t => { + const { smpp } = await submitInFlight(t, {}, { shutdownTimeout: 50 }); + const closed = await peerOf(smpp).close(); + + assert.ok(closed.err instanceof Error); +- assert.match(closed.err.message, /1 message\(s\) unanswered/); ++ assert.match(closed.err.message, /1 message\(s\) still being handled/); + }); + +- // Waiting forever is safe for the peer, which every request times out on. The application is not. +- test('falls back to responseTimeout for a held message when the shutdown waits forever', async t => { +- const { smpp } = await submitInFlight(t, {}, { responseTimeout: 200, shutdownTimeout: 0 }); ++ test('waits for a handler as long as it runs when the shutdown waits forever', async t => { ++ const { box, smpp, sms } = await submitInFlight(t, {}, { shutdownTimeout: 0 }); + const started = Date.now(); +- const closed = await peerOf(smpp).close(); +- const waited = Date.now() - started; ++ const closing = peerOf(smpp).close(); + +- assert.ok(closed.err instanceof Error); +- assert.match(closed.err.message, /1 message\(s\) unanswered/); +- assert.ok(waited >= 190, `waited ${String(waited)} ms, so the fallback was not what bounded it`); +- assert.ok(waited < 2000); ++ await delay(150); ++ await box.answer(sms); ++ ++ assert.deepEqual(await closing, {}); ++ assert.ok(Date.now() - started >= 140, 'nothing but the handler returning ended the wait'); + }); + + // leftOf() floors what is left at 1 ms: at 0 the request half would read "wait forever" instead. + test('still ends when the message half has spent the whole shutdown budget', async t => { +- const { smpp } = await submitInFlight(t, {}, { shutdownTimeout: 100 }); ++ const { smpp } = await submitInFlight(t, { onSms: stuck }, { shutdownTimeout: 100 }); + const bound = peerOf(smpp); +- // The client listens for no 'sms', so this one is never answered and stays in the window. ++ // The client's handler never returns, so this one is never answered and stays in the window. + const unanswered = bound.send({ + cmdName: 'submit_sm', + params: { +@@ -2876,14 +3108,13 @@ describe('graceful shutdown', () => { + }), + ]); + +- assert.match(closed.err?.message ?? '', /1 message\(s\) unanswered; .*1 request\(s\) unfinished/); ++ assert.match(closed.err?.message ?? '', /1 message\(s\) still being handled; .*1 request\(s\) unfinished/); + assert.ok((await unanswered).err instanceof Error); + }); + +- // The README's own listener answers and then sends its receipt, one turn later. Multipart, because +- // a receipt sent one-after-a-response outruns that turn on every segment past the first. +- test('a receipt sent right after the response still goes out mid-drain', async t => { +- const { sent, session, smpp, sms } = await submitInFlight(t, {}, {}, 'x'.repeat(400)); ++ // Multipart, because a receipt has one deliver_sm per segment and every one has to pass the drain. ++ test('a receipt sent from a running handler still goes out mid-drain', async t => { ++ const { box, sent, session, smpp, sms } = await submitInFlight(t, {}, {}, 'x'.repeat(400)); + const received: Dlr[] = []; + const receipts = once(resolve => { + session.on('dlr', dlr => { +@@ -2901,102 +3132,92 @@ describe('graceful shutdown', () => { + + assert.equal(receiptSent.err, undefined); + assert.deepEqual((await receipts).map(dlr => dlr.smsId), ids); ++ box.release(sms); + assert.deepEqual(await closing, {}); + assert.deepEqual((await sent).smsIds, ids); + }); + +- test('a message no listener took does not hold the shutdown up', async t => { ++ test('a message no handler takes is refused at once, and holds nothing', async t => { + const smpp = await startServer(t, { shutdownTimeout: 30_000 }); + const { session } = await connect(t, smpp); + + assert.ok(session); + + const bound = peerOf(smpp); +- const arrived = once(resolve => { +- bound.on('incomingPduObj', pduObj => { +- if (pduObj.cmdName === 'submit_sm') resolve(pduObj); +- }); +- }); +- const sent = session.sendSms({ ++ const reported = once(resolve => { bound.on('sessionError', resolve); }); ++ const sent = await session.sendSms({ + from: '46701113311', + message: 'nobody is listening', + to: '46709771337', + }); +- +- await arrived; +- await delay(50); +- + const started = Date.now(); + ++ assert.match(sent.err?.message ?? '', /ESME_RTHROTTLED/, 'the peer keeps the message'); ++ assert.match((await reported).message, /No onSms handler/); + assert.deepEqual(await bound.close(), {}); + assert.ok(Date.now() - started < 1000); +- assert.ok((await sent).err instanceof Error); + }); + +- // emit() releases the hold of a listener that throws; one that rejects may cost no more than that. +- test('a listener that rejected before answering does not hold the shutdown up', async t => { +- const smpp = await startServer(t, { shutdownTimeout: 30_000 }); ++ test('a handler that rejected before answering has the message refused for it, and holds nothing', async t => { ++ const smpp = await startServer(t, { ++ onSms: () => Promise.reject(new Error('the handler gave up')), ++ shutdownTimeout: 30_000, ++ }); + const failed = once(resolve => { +- smpp.on('session', bound => { +- bound.on('sessionError', resolve); +- bound.on('sms', () => Promise.reject(new Error('the listener gave up'))); +- }); ++ smpp.on('session', bound => { bound.on('sessionError', resolve); }); + }); + const { session } = await connect(t, smpp); + + assert.ok(session); + +- const sent = session.sendSms({ ++ const sent = await session.sendSms({ + from: '46701113311', +- message: 'the listener rejects', ++ message: 'the handler rejects', + to: '46709771337', + }); + +- assert.equal((await failed).message, 'the listener gave up'); ++ assert.equal((await failed).message, 'the handler gave up'); ++ assert.match(sent.err?.message ?? '', /ESME_RTHROTTLED/); + + const started = Date.now(); + + assert.deepEqual(await peerOf(smpp).close(), {}); + assert.ok(Date.now() - started < 1000); +- assert.ok((await sent).err instanceof Error); + }); + +- test('waits for the listener still working when another one rejected', async t => { +- const smpp = await startServer(t, { shutdownTimeout: 30_000 }); +- const failed = once(resolve => { +- smpp.on('session', bound => { +- bound.on('sessionError', resolve); +- bound.on('sms', async sms => { +- await delay(100); +- await sms.sendResp({ smsId: 'answered-after-the-other-gave-up' }); +- }); +- bound.on('sms', () => Promise.reject(new Error('the audit listener gave up'))); +- }); ++ test('close() keeps waiting for a handler that answered and is still working', async t => { ++ const smpp = await startServer(t, { ++ onSms: async sms => { ++ await sms.sendResp({ smsId: 'answered-then-kept-working' }); ++ await delay(100); ++ }, + }); + const { session } = await connect(t, smpp); + + assert.ok(session); + +- const sent = session.sendSms({ ++ const sent = await session.sendSms({ + from: '46701113311', +- message: 'two listeners, one gives up', ++ message: 'answered early, finished late', + to: '46709771337', + }); ++ const started = Date.now(); + +- assert.equal((await failed).message, 'the audit listener gave up'); ++ assert.deepEqual(sent.smsIds, ['answered-then-kept-working']); + assert.deepEqual(await peerOf(smpp).close(), {}); +- assert.deepEqual((await sent).smsIds, ['answered-after-the-other-gave-up']); ++ assert.ok(Date.now() - started >= 50, 'the answer is not what ends the wait; the handler is'); + }); + + // Nothing reached the peer, so a drain counting this answered would report an outcome that never was. +- test('leaves a message the library refused to answer unanswered', async t => { ++ test('keeps waiting on a handler whose answer the library refused', async t => { + const { sms, smpp } = await submitInFlight(t, {}, { shutdownTimeout: 50 }); + const refused = await sms.sendResp({ smsId: '' }); + const closed = await peerOf(smpp).close(); + + assert.match(refused.err?.message ?? '', /smsId must not be empty/); ++ assert.equal(sms.answered, false); + assert.ok(closed.err instanceof Error); +- assert.match(closed.err.message, /1 message\(s\) unanswered/); ++ assert.match(closed.err.message, /1 message\(s\) still being handled/); + }); + + test('gives up on a request that outlasts shutdownTimeout', async t => { +@@ -3031,8 +3252,8 @@ describe('graceful shutdown', () => { + + // The queued segments are the whole reason the drain waits on the window and not on the pending map. + test('counts the segments still queued behind a full window', async t => { +- // No 'sms' listener, so the single-segment message holding the only slot is never answered. +- const smpp = await startServer(t); ++ // A handler that never returns, so the single-segment message holding the only slot is never answered. ++ const smpp = await startServer(t, { onSms: stuck }); + const onWire = once(resolve => { + smpp.on('session', bound => bound.on('incomingPduObj', resolve)); + }); +@@ -3174,6 +3395,7 @@ describe('graceful shutdown', () => { + await delay(50); + + assert.deepEqual(reported, []); ++ assert.equal(session.state, 'ended'); + }); + + test('answers a peer\'s unbind before asking the session to end', async t => { +@@ -3208,12 +3430,7 @@ describe('message id notation', () => { + } + + test('correlates a hex submit_sm_resp against a decimal receipt', async t => { +- const smpp = await startServer(t); +- +- smpp.on('session', bound => { +- bound.on('sms', sms => { void sms.sendResp({ smsId: '1a2b' }); }); +- }); +- ++ const smpp = await startServer(t, { onSms: sms => sms.sendResp({ smsId: '1a2b' }) }); + const { session } = await connect(t, smpp, { + smsIdFormat: { receipt: 'decimal', submitResp: 'hex' }, + }); +@@ -3238,13 +3455,9 @@ describe('message id notation', () => { + }); + + test('leaves the segment ids of a multipart send to merge as they are', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', bound => bound.on('sms', sms => { +- resolve(sms); +- void sms.sendResp(); +- })); +- }); ++ const box = inbox(); ++ const smpp = await startServer(t, { onSms: box.onSms }); ++ const incoming = box.next(); + const { session } = await connect(t, smpp, { + smsIdFormat: { receipt: 'decimal', submitResp: 'hex' }, + }); +diff --git a/test/session.test.ts b/test/session.test.ts +index 1f31f0b..1119c8f 100644 +--- a/test/session.test.ts ++++ b/test/session.test.ts +@@ -3,14 +3,15 @@ import net from 'node:net'; + import test, { describe } from 'node:test'; + import type { Dlr } from '../src/dlr.ts'; + import type { PduObject, PduObjectInput } from '../src/pdu.ts'; +-import type { Sms } from '../src/sms.ts'; ++import type { ClientOptions } from '../src/client.ts'; ++import type { OnSms } from '../src/session-options.ts'; ++import type { SendRespOptions, Sms } from '../src/sms.ts'; + import type { ServerOptions, SmppServer } from '../src/server.ts'; + import type { SmppLog } from '../src/log.ts'; + import type { TestContext } from 'node:test'; + import type { VoidResult } from '../src/result.ts'; + import { DlrMerger } from '../src/dlr-merger.ts'; + import { PduFramer } from '../src/pdu-framer.ts'; +-import { ReconnectLoop } from '../src/reconnect-loop.ts'; + import { Session, bindCommands } from '../src/session.ts'; + import { checkSessionOptions } from '../src/session-options.ts'; + import { client } from '../src/client.ts'; +@@ -53,6 +54,53 @@ function once(register: (resolve: (value: T) => void) => void): Promise { + return new Promise(resolve => { register(resolve); }); + } + ++type Inbox = { ++ /** Answers the message and lets its handler return. */ ++ answer: (sms: Sms, options?: SendRespOptions) => Promise; ++ /** The next message handed over; its handler runs until the test answers or releases it. */ ++ next: () => Promise; ++ onSms: OnSms; ++ release: (sms: Sms) => void; ++}; ++ ++/** An application that hands each message to the test and stays in the handler until told it is done. */ ++function inbox(): Inbox { ++ const queued: Sms[] = []; ++ const waiting: ((sms: Sms) => void)[] = []; ++ const releases = new Map void>(); ++ const release = (sms: Sms): void => { ++ releases.get(sms)?.(); ++ releases.delete(sms); ++ }; ++ ++ return { ++ answer: async (sms, options) => { ++ const answered = await sms.sendResp(options); ++ ++ release(sms); ++ ++ return answered; ++ }, ++ next: () => { ++ const sms = queued.shift(); ++ ++ return sms ? Promise.resolve(sms) : once(resolve => waiting.push(resolve)); ++ }, ++ onSms: sms => new Promise(done => { ++ releases.set(sms, done); ++ ++ const next = waiting.shift(); ++ ++ if (next) next(sms); ++ else queued.push(sms); ++ }), ++ release, ++ }; ++} ++ ++/** A handler that never returns, for a message that must stay held. */ ++const stuck: OnSms = () => new Promise(() => undefined); ++ + function delay(ms: number): Promise { + return new Promise(resolve => { setTimeout(resolve, ms); }); + } +@@ -507,10 +555,8 @@ describe('bind direction', () => { + }); + + test('refuses sendSms() on a receiver-bound session before it reaches the wire', async t => { +- const smpp = await startServer(t); + const arrived: Sms[] = []; +- +- smpp.on('session', peer => peer.on('sms', sms => arrived.push(sms))); ++ const smpp = await startServer(t, { onSms: sms => arrived.push(sms) }); + + const { session } = await connect(t, smpp, { bindType: 'receiver' }); + +@@ -542,10 +588,9 @@ describe('bind direction', () => { + }); + + test('refuses sendDlr() to a transmitter-bound peer before it reaches the wire', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', peer => peer.on('sms', resolve)); +- }); ++ const box = inbox(); ++ const smpp = await startServer(t, { onSms: box.onSms }); ++ const incoming = box.next(); + const { session } = await connect(t, smpp, { bindType: 'transmitter' }); + + assert.ok(session); +@@ -585,10 +630,7 @@ describe('bind direction', () => { + }); + + test('refuses a data_sm from a receiver-bound peer, and carries one from a transmitter', async t => { +- const smpp = await startServer(t); +- +- smpp.on('session', peer => peer.on('sms', sms => void sms.sendResp())); +- ++ const smpp = await startServer(t, { onSms: () => undefined }); + const receiving = await connect(t, smpp, { bindType: 'receiver' }); + + assert.ok(receiving.session); +@@ -619,10 +661,9 @@ describe('bind direction', () => { + + describe('sending', () => { + test('delivers a simple SMS with the sender TON derived from the address', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); ++ const box = inbox(); ++ const smpp = await startServer(t, { onSms: box.onSms }); ++ const incoming = box.next(); + const { session } = await connect(t, smpp); + + assert.ok(session); +@@ -656,11 +697,10 @@ describe('sending', () => { + }); + + test('reassembles a long SMS and answers every segment', async t => { +- const smpp = await startServer(t); ++ const box = inbox(); ++ const smpp = await startServer(t, { onSms: box.onSms }); + const message = 'Lorem ipsum dolor sit amet, '.repeat(20); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); ++ const incoming = box.next(); + const { session } = await connect(t, smpp); + + assert.ok(session); +@@ -682,11 +722,10 @@ describe('sending', () => { + }); + + test('carries a UCS2 message through unchanged', async t => { +- const smpp = await startServer(t); ++ const box = inbox(); ++ const smpp = await startServer(t, { onSms: box.onSms }); + const message = 'räksmörgås تست 一'; +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); ++ const incoming = box.next(); + const { session } = await connect(t, smpp); + + assert.ok(session); +@@ -705,10 +744,9 @@ describe('sending', () => { + }); + + test('marks a flash message without losing the UCS2 alphabet', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); ++ const box = inbox(); ++ const smpp = await startServer(t, { onSms: box.onSms }); ++ const incoming = box.next(); + const { session } = await connect(t, smpp); + + assert.ok(session); +@@ -729,10 +767,9 @@ describe('sending', () => { + }); + + test('puts the address TON and NPI the caller chose on the wire', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', peer => peer.on('sms', resolve)); +- }); ++ const box = inbox(); ++ const smpp = await startServer(t, { onSms: box.onSms }); ++ const incoming = box.next(); + const { session } = await connect(t, smpp); + + assert.ok(session); +@@ -768,11 +805,12 @@ describe('receiving', () => { + async function inbound( + t: TestContext, + options: ServerOptions = {}, ++ clientOptions: ClientOptions = {}, + ): Promise<{ peer: Session; session: Session }> { + const smpp = await startServer(t, options); + + const bound = once(resolve => { smpp.on('session', resolve); }); +- const { session } = await connect(t, smpp); ++ const { session } = await connect(t, smpp, clientOptions); + + assert.ok(session); + +@@ -780,8 +818,9 @@ describe('receiving', () => { + } + + test('hands a client a deliver_sm that is not a delivery receipt', async t => { +- const { peer, session } = await inbound(t); +- const incoming = once(resolve => { session.on('sms', resolve); }); ++ const box = inbox(); ++ const { peer } = await inbound(t, {}, { onSms: box.onSms }); ++ const incoming = box.next(); + const delivered = peer.send({ + cmdName: 'deliver_sm', + params: { +@@ -813,8 +852,9 @@ describe('receiving', () => { + + // SMPP 3.4 5.3.2.32: up to 64 KB of body in a TLV, with sm_length 0 and short_message empty. + test('reads an inbound message the peer carried in message_payload', async t => { +- const { peer, session } = await inbound(t); +- const incoming = once(resolve => { session.on('sms', resolve); }); ++ const box = inbox(); ++ const { peer } = await inbound(t, {}, { onSms: box.onSms }); ++ const incoming = box.next(); + const text = 'the whole body, carried in the TLV'; + const delivered = peer.send({ + cmdName: 'deliver_sm', +@@ -841,8 +881,9 @@ describe('receiving', () => { + }); + + test('hands a client a data_sm carrying a message as an sms, answered data_sm_resp', async t => { +- const { peer, session } = await inbound(t); +- const incoming = once(resolve => { session.on('sms', resolve); }); ++ const box = inbox(); ++ const { peer } = await inbound(t, {}, { onSms: box.onSms }); ++ const incoming = box.next(); + const smsId = '0199e0f1-6c31-7a44-9d02-4b7e51c3a806'; + const delivered = peer.send({ + cmdName: 'data_sm', +@@ -867,12 +908,10 @@ describe('receiving', () => { + }); + + test('hands a client a receipt carried on data_sm as a dlr', async t => { +- const { peer, session } = await inbound(t); ++ let messages = 0; ++ const { peer, session } = await inbound(t, {}, { onSms: () => { messages++; } }); + const reported = once(resolve => { session.on('dlr', resolve); }); + const smsId = '0199e0f1-b8a2-7f19-8c63-2d5041fb9e77'; +- let messages = 0; +- +- session.on('sms', () => { messages++; }); + + const delivered = peer.send({ + cmdName: 'data_sm', +@@ -903,18 +942,12 @@ describe('receiving', () => { + + // At the SMSC end an inbound data_sm is a submission, so nothing in one reports on our own sends. + test('reads a receipt-shaped data_sm submitted to a server as the message it is', async t => { +- const smpp = await startServer(t); + const body = 'id:0199e0f2-2d15-7b83-a4c1-6e90b7d2f345 stat:DELIVRD err:000 text:'; + const messages: Sms[] = []; + const reports: Dlr[] = []; ++ const smpp = await startServer(t, { onSms: sms => { messages.push(sms); } }); + +- smpp.on('session', peer => { +- peer.on('dlr', dlr => { reports.push(dlr); }); +- peer.on('sms', sms => { +- messages.push(sms); +- void sms.sendResp(); +- }); +- }); ++ smpp.on('session', peer => { peer.on('dlr', dlr => { reports.push(dlr); }); }); + + const { session } = await connect(t, smpp, { bindType: 'transmitter' }); + +@@ -965,8 +998,9 @@ describe('receiving', () => { + }); + + test('reassembles a concatenated message whose segments arrived in message_payload', async t => { +- const { peer, session } = await inbound(t); +- const incoming = once(resolve => { session.on('sms', resolve); }); ++ const box = inbox(); ++ const { peer } = await inbound(t, {}, { onSms: box.onSms }); ++ const incoming = box.next(); + const text = 'A body in the TLV is still numbered by its UDH. '.repeat(6); + const segments = splitMessage(text, { reference: 0x3B }); + +@@ -994,7 +1028,7 @@ describe('receiving', () => { + + assert.ok(sms, 'the segments join into one message wherever their bodies were carried'); + assert.equal(sms.message, text); +- assert.equal(sms.answeredOnArrival, true); ++ assert.equal(sms.answered, true); + assert.deepEqual(answers.map(answer => answer.cmdStatus), answers.map(() => 'ESME_ROK')); + assert.deepEqual( + answers.map(answer => paramText(answer.params.message_id)), +@@ -1005,11 +1039,9 @@ describe('receiving', () => { + }); + + test('hands a client a report as a dlr rather than as an sms', async t => { +- const { peer, session } = await inbound(t); +- const reported = once(resolve => { session.on('dlr', resolve); }); + let messages = 0; +- +- session.on('sms', () => { messages++; }); ++ const { peer, session } = await inbound(t, {}, { onSms: () => { messages++; } }); ++ const reported = once(resolve => { session.on('dlr', resolve); }); + + const delivered = peer.send({ + cmdName: 'deliver_sm', +@@ -1085,8 +1117,9 @@ describe('receiving', () => { + + test('reassembles a multipart inbound SMS before the sms event', async t => { + const message = 'Inbound lorem ipsum dolor sit amet consectetur, '.repeat(6); +- const { peer, session } = await inbound(t); +- const incoming = once(resolve => { session.on('sms', resolve); }); ++ const box = inbox(); ++ const { peer } = await inbound(t, {}, { onSms: box.onSms }); ++ const incoming = box.next(); + const segments = splitMessage(message, { reference: 42 }); + + assert.equal(segments.length, 2); +@@ -1124,8 +1157,9 @@ describe('receiving', () => { + } + + test('answers every sar_* segment on arrival and hands the application one message', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { smpp.on('session', peer => peer.on('sms', resolve)); }); ++ const box = inbox(); ++ const smpp = await startServer(t, { onSms: box.onSms }); ++ const incoming = box.next(); + const { session } = await connect(t, smpp, { bindType: 'transmitter', responseTimeout: 1000 }); + + assert.ok(session); +@@ -1153,7 +1187,7 @@ describe('receiving', () => { + + assert.ok(sms, 'the sar_* TLVs tie the three submissions into one message'); + assert.equal(sms.message, parts.join('')); +- assert.equal(sms.answeredOnArrival, true); ++ assert.equal(sms.answered, true); + assert.deepEqual( + answers.map(answer => paramText(answer.params.message_id)), + [1, 2, 3].map(part => `${sms.smsId}-${String(part)}`), +@@ -1161,8 +1195,9 @@ describe('receiving', () => { + }); + + test('joins sar_* segments in the order they number themselves', async t => { +- const { peer, session } = await inbound(t, { responseTimeout: 1000 }); +- const incoming = once(resolve => { session.on('sms', resolve); }); ++ const box = inbox(); ++ const { peer } = await inbound(t, { responseTimeout: 1000 }, { onSms: box.onSms }); ++ const incoming = box.next(); + const parts = ['first ', 'second ', 'third']; + + for (const index of [2, 0, 1]) { +@@ -1187,8 +1222,9 @@ describe('receiving', () => { + }); + + test('reassembles a sar_* segment whose body is in message_payload', async t => { +- const { peer, session } = await inbound(t, { responseTimeout: 1000 }); +- const incoming = once(resolve => { session.on('sms', resolve); }); ++ const box = inbox(); ++ const { peer } = await inbound(t, { responseTimeout: 1000 }, { onSms: box.onSms }); ++ const incoming = box.next(); + const parts = ['in the mandatory field, ', 'and in the TLV']; + + for (const [index, part] of parts.entries()) { +@@ -1218,15 +1254,13 @@ describe('receiving', () => { + + assert.ok(sms, 'a segment carries its body where any other message may carry one'); + assert.equal(sms.message, parts.join('')); +- assert.equal(sms.answeredOnArrival, true); ++ assert.equal(sms.answered, true); + }); + + // The UDH reference is 8 bits and sar_msg_ref_num is 16, so the same number is two messages. + test('keeps a UDH group and a sar_* group sharing a reference apart', async t => { +- const { peer, session } = await inbound(t, { responseTimeout: 1000 }); + const messages: Sms[] = []; +- +- session.on('sms', sms => { messages.push(sms); }); ++ const { peer } = await inbound(t, { responseTimeout: 1000 }, { onSms: sms => { messages.push(sms); } }); + + const udhText = 'the message numbered by its user data header. '.repeat(5); + const udhSegments = splitMessage(udhText, { reference: 5 }); +@@ -1271,8 +1305,9 @@ describe('receiving', () => { + + // Nothing compares the two references: each spelling counts in a space of its own. + test('groups a segment carrying both spellings by its UDH', async t => { +- const { peer, session } = await inbound(t, { responseTimeout: 1000 }); +- const incoming = once(resolve => { session.on('sms', resolve); }); ++ const box = inbox(); ++ const { peer } = await inbound(t, { responseTimeout: 1000 }, { onSms: box.onSms }); ++ const incoming = box.next(); + const message = 'both spellings on every segment of it, and the UDH decides. '.repeat(4); + const segments = splitMessage(message, { reference: 7 }); + +@@ -1301,12 +1336,11 @@ describe('receiving', () => { + }); + + test('reads a receipt carrying sar_* fields as a dlr, never as a segment', async t => { +- const { peer, session } = await inbound(t, { responseTimeout: 1000 }); +- const reports: Dlr[] = []; + let messages = 0; ++ const { peer, session } = await inbound(t, { responseTimeout: 1000 }, { onSms: () => { messages++; } }); ++ const reports: Dlr[] = []; + + session.on('dlr', dlr => { reports.push(dlr); }); +- session.on('sms', () => { messages++; }); + + const marked = '0199e1a4-6c3f-7d21-9a80-5b1e2f7c4d63'; + const unmarked = '0199e1a4-b70e-7c55-8f42-9d3a1c86e70b'; +@@ -1350,10 +1384,9 @@ describe('receiving', () => { + + describe('delivery reports', () => { + test('reaches the sender as a dlr event', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); ++ const box = inbox(); ++ const smpp = await startServer(t, { onSms: box.onSms }); ++ const incoming = box.next(); + const { session } = await connect(t, smpp); + + assert.ok(session); +@@ -1383,10 +1416,9 @@ describe('delivery reports', () => { + }); + + test('reports a failure with the spec status code', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); ++ const box = inbox(); ++ const smpp = await startServer(t, { onSms: box.onSms }); ++ const incoming = box.next(); + const { session } = await connect(t, smpp); + + assert.ok(session); +@@ -1417,10 +1449,9 @@ describe('delivery reports', () => { + }); + + test('sends a text-only receipt to a peer that declared less than 3.4', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); ++ const box = inbox(); ++ const smpp = await startServer(t, { onSms: box.onSms }); ++ const incoming = box.next(); + const peer = rawPeer(t, smpp.port); + + peer.write(bindOf(0x33)); +@@ -1454,10 +1485,9 @@ describe('delivery reports', () => { + }); + + test('merges nothing for a message that asked for no receipt', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); ++ const box = inbox(); ++ const smpp = await startServer(t, { onSms: box.onSms }); ++ const incoming = box.next(); + const { session } = await connect(t, smpp); + + assert.ok(session); +@@ -1497,10 +1527,9 @@ describe('a session captured from Kannel', () => { + const expected = 'Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry\'s standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum'; + + async function replay(t: TestContext, order: number[]): Promise { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); ++ const box = inbox(); ++ const smpp = await startServer(t, { onSms: box.onSms }); ++ const incoming = box.next(); + + const sock = net.connect({ port: smpp.port }, () => { + sock.write(Buffer.from('0000002100000009000000000000002f666f6f0062617200736d70700034000000', 'hex')); +@@ -1598,19 +1627,15 @@ describe('robustness', () => { + }); + + test('keeps at most maxOutstanding requests on the wire', async t => { +- const smpp = await startServer(t); + let concurrent = 0; + let peak = 0; +- +- smpp.on('session', session => { +- session.on('sms', sms => { ++ const smpp = await startServer(t, { ++ onSms: async () => { + concurrent++; + peak = Math.max(peak, concurrent); +- setTimeout(() => { +- concurrent--; +- void sms.sendResp(); +- }, 10); +- }); ++ await delay(10); ++ concurrent--; ++ }, + }); + + const { session } = await connect(t, smpp, { maxOutstanding: 2 }); +@@ -1653,12 +1678,7 @@ describe('robustness', () => { + }); + + test('ignores events from the socket it left behind on a reconnect', async t => { +- const smpp = await startServer(t); +- +- smpp.on('session', bound => { +- bound.on('sms', sms => { void sms.sendResp(); }); +- }); +- ++ const smpp = await startServer(t, { onSms: () => undefined }); + const { session } = await connect(t, smpp, { reconnect: { maxDelay: 50, minDelay: 10 } }); + + assert.ok(session); +@@ -1731,10 +1751,8 @@ describe('robustness', () => { + + // The guard sits before the send window, or a full window makes the aborted call queue first. + test('does not wait for a send window slot it will never use', async t => { +- const smpp = await startServer(t); +- + // The peer answers nothing, so the one slot stays held for the whole test. +- smpp.on('session', session => session.on('sms', () => undefined)); ++ const smpp = await startServer(t, { onSms: stuck }); + + const { session } = await connect(t, smpp, { maxOutstanding: 1, responseTimeout: 5000 }); + +@@ -1756,45 +1774,6 @@ describe('robustness', () => { + assert.ok(aborted !== false && aborted.err instanceof Error); + }); + +- // A socket the loop opened and never handed over is one leaked per retry, forever. +- test('leaves no socket open when coming back up fails', async t => { +- const opened: net.Socket[] = []; +- +- function onConnected(): Promise { +- if (opened.length === 1) return Promise.resolve({ err: new Error('bind refused') }); +- +- throw new Error('bind exploded'); +- } +- +- const loop = new ReconnectLoop({ +- connect: () => { +- const sock = new net.Socket(); +- +- opened.push(sock); +- +- return Promise.resolve({ sock }); +- }, +- log: silentLog, +- maxDelay: 10, +- minDelay: 1, +- onConnected, +- }); +- +- t.after(() => { +- loop.stop(); +- +- for (const sock of opened) { +- sock.destroy(); +- } +- }); +- loop.schedule(); +- +- const destroyed = await waitFor(() => opened.length >= 2 +- && opened[0]?.destroyed === true +- && opened[1]?.destroyed === true); +- +- assert.ok(destroyed, 'a failed setup should leave no socket open'); +- }); + }); + + describe('a PDU the codec cannot read', () => { +@@ -1972,13 +1951,10 @@ describe('application hooks that throw or reject', () => { + assert.equal(reported.message, 'authenticate exploded'); + }); + +- test('turns a throwing sms listener into a session error', async t => { +- const smpp = await startServer(t); ++ test('turns a throwing onSms handler into a session error, and refuses its message', async t => { ++ const smpp = await startServer(t, { onSms: () => { throw new Error('handler exploded'); } }); + const failed = once(resolve => { +- smpp.on('session', session => { +- session.on('sessionError', resolve); +- session.on('sms', () => { throw new Error('listener exploded'); }); +- }); ++ smpp.on('session', session => { session.on('sessionError', resolve); }); + }); + const { session } = await connect(t, smpp, { responseTimeout: 200 }); + +@@ -1992,17 +1968,17 @@ describe('application hooks that throw or reject', () => { + const reported = await raceWithin(500, failed); + + assert.ok(sent.err instanceof Error); +- assert.ok(reported instanceof Error, 'a throwing sms listener should reach the session'); +- assert.equal(reported.message, 'listener exploded'); ++ assert.match(sent.err.message, /ESME_RTHROTTLED/, 'a handler that failed decided nothing, so the peer keeps the message'); ++ assert.ok(reported instanceof Error, 'a throwing handler should reach the session'); ++ assert.equal(reported.message, 'handler exploded'); + }); + +- // The guard for a throwing sms listener used to emit sessionError from inside its own catch. ++ // The guard for a failing handler used to emit sessionError from inside its own catch. + test('survives a sessionError listener that throws as well', async t => { +- const smpp = await startServer(t); ++ const smpp = await startServer(t, { onSms: () => { throw new Error('handler exploded'); } }); + + smpp.on('session', session => { + session.on('sessionError', () => { throw new Error('the reporter exploded too'); }); +- session.on('sms', () => { throw new Error('listener exploded'); }); + }); + + const { session } = await connect(t, smpp, { responseTimeout: 200 }); +@@ -2018,18 +1994,17 @@ describe('application hooks that throw or reject', () => { + assert.ok(sent.err instanceof Error); + }); + +- test('normalises whatever a rejecting async sms listener threw into a session error', async t => { +- const smpp = await startServer(t); ++ test('normalises whatever a rejecting async onSms handler threw into a session error', async t => { + const reason: unknown = null; +- const failed = once(resolve => { +- smpp.on('session', session => { +- session.on('sessionError', resolve); +- session.on('sms', async sms => { +- await sms.sendResp(); ++ const smpp = await startServer(t, { ++ onSms: async sms => { ++ await sms.sendResp(); + +- throw reason; +- }); +- }); ++ throw reason; ++ }, ++ }); ++ const failed = once(resolve => { ++ smpp.on('session', session => { session.on('sessionError', resolve); }); + }); + const { session } = await connect(t, smpp, { responseTimeout: 200 }); + +@@ -2042,17 +2017,16 @@ describe('application hooks that throw or reject', () => { + }); + const reported = await raceWithin(500, failed); + +- assert.equal(sent.err, undefined); +- assert.ok(reported instanceof Error, 'a rejecting sms listener should reach the session'); ++ assert.equal(sent.err, undefined, 'a handler that answered and then failed leaves its answer alone'); ++ assert.ok(reported instanceof Error, 'a rejecting handler should reach the session'); + assert.equal(reported.message, 'null'); + }); + + test('survives a sessionError listener that rejects as well', async t => { +- const smpp = await startServer(t); ++ const smpp = await startServer(t, { onSms: () => Promise.reject(new Error('handler rejected')) }); + + smpp.on('session', session => { + session.on('sessionError', () => Promise.reject(new Error('the reporter rejected too'))); +- session.on('sms', () => Promise.reject(new Error('listener rejected'))); + }); + + const { session } = await connect(t, smpp, { responseTimeout: 200 }); +@@ -2099,11 +2073,7 @@ describe('application hooks that throw or reject', () => { + test('sends on through an application logger that throws', async t => { + const thrower = (): void => { throw new Error('the logger exploded'); }; + const log: SmppLog = { debug: thrower, error: thrower, info: thrower, verbose: thrower, warn: thrower }; +- const smpp = await startServer(t, { log }); +- +- smpp.on('session', session => { +- session.on('sms', sms => { void sms.sendResp(); }); +- }); ++ const smpp = await startServer(t, { log, onSms: () => undefined }); + + const { session } = await connect(t, smpp, { log }); + +@@ -2130,12 +2100,7 @@ describe('application hooks that throw or reject', () => { + }); + + test('keeps the message id off a submit_sm_resp that refuses the message', async t => { +- const smpp = await startServer(t); +- +- smpp.on('session', bound => { +- bound.on('sms', sms => { void sms.sendResp({ status: 'ESME_RMSGQFUL' }); }); +- }); +- ++ const smpp = await startServer(t, { onSms: sms => sms.sendResp({ status: 'ESME_RMSGQFUL' }) }); + const peer = rawPeer(t, smpp.port); + + peer.write(bindOf(0x34)); +@@ -2158,103 +2123,40 @@ describe('application hooks that throw or reject', () => { + assert.deepEqual(refused.params, {}); + }); + +- test('keeps the reconnect loop alive when connect throws', async t => { +- let attempts = 0; +- const loop = new ReconnectLoop({ +- connect: () => { +- attempts++; +- +- throw new Error('connect exploded'); +- }, +- log: silentLog, +- maxDelay: 10, +- minDelay: 1, +- onConnected: () => Promise.resolve({}), +- }); +- +- t.after(() => { loop.stop(); }); +- loop.schedule(); +- +- const retried = await waitFor(() => attempts >= 2); +- +- assert.ok(retried, 'a throwing connect should be retried, not left for the process to die on'); +- }); +- +- test('keeps backing off when every link dies as soon as it comes up', async t => { +- const clock = { now: 0 }; +- const delays: number[] = []; +- const noop = (): void => undefined; +- const log: SmppLog = { +- debug: noop, +- error: noop, +- info: (msg, metadata) => { +- if (msg === 'reconnect - retrying') delays.push(Number(metadata?.delay)); +- }, +- verbose: noop, +- warn: noop, +- }; +- let up = 0; +- const loop = new ReconnectLoop({ +- connect: () => Promise.resolve({ sock: new net.Socket() }), +- log, +- maxDelay: 80, +- minDelay: 10, +- now: () => clock.now, +- onConnected: () => { +- up++; +- +- return Promise.resolve({}); +- }, +- }); +- +- t.after(() => { loop.stop(); }); +- +- for (let died = 0; died < 4; died++) { +- loop.schedule(); +- await waitFor(() => up === died + 1); +- await delay(5); +- } +- +- assert.deepEqual(delays, [10, 20, 40, 80]); +- +- // A link that outlasted the longest wait earned a fresh start. +- clock.now += 80; +- loop.schedule(); +- await waitFor(() => delays.length === 5); ++ // A socket the loop opened and never handed over is one leaked per retry, forever. ++ test('leaves no socket open when coming back up fails', async t => { ++ const opened: net.Socket[] = []; ++ const first = new net.Socket(); ++ const session = new Session({ ++ reconnect: { ++ connect: () => { ++ const sock = new net.Socket(); + +- assert.deepEqual(delays, [10, 20, 40, 80, 10]); +- }); ++ opened.push(sock); + +- test('starts only one reconnect attempt at a time', async t => { +- let attempts = 0; +- let finish: (() => void) | undefined; +- const loop = new ReconnectLoop({ +- connect: () => { +- attempts++; ++ return Promise.resolve({ sock }); ++ }, ++ maxDelay: 10, ++ minDelay: 1, ++ onConnected: () => { ++ if (opened.length === 1) return Promise.resolve({ err: new Error('bind refused') }); + +- return new Promise(resolve => { +- finish = () => { resolve({ err: new Error('no socket') }); }; +- }); ++ throw new Error('bind exploded'); ++ }, + }, +- log: silentLog, +- maxDelay: 5, +- minDelay: 1, +- onConnected: () => Promise.resolve({}), ++ sock: first, + }); + +- t.after(() => { +- loop.stop(); +- finish?.(); +- }); +- loop.schedule(); +- +- assert.ok(await waitFor(() => attempts === 1)); ++ closeAfter(t, session); ++ t.after(() => { for (const sock of opened) sock.destroy(); }); ++ first.destroy(); + +- // A second drop landing while the first attempt is still inside connect(). +- loop.schedule(); +- await delay(30); ++ const destroyed = await waitFor(() => opened.length >= 2 ++ && opened[0]?.destroyed === true ++ && opened[1]?.destroyed === true); + +- assert.equal(attempts, 1); ++ assert.ok(destroyed, 'a failed setup should leave no socket open'); ++ assert.equal(session.state, 'down'); + }); + }); + +@@ -2269,6 +2171,7 @@ describe('the server\'s onRequest hook', () => { + } + + test('refuses an inbound submit_sm with the status the hook chose', async t => { ++ let messages = 0; + const smpp = await startServer(t, { + onRequest: async (bound, pduObj) => { + if (!isCommand(pduObj, 'submit_sm')) return false; +@@ -2277,10 +2180,8 @@ describe('the server\'s onRequest hook', () => { + + return true; + }, ++ onSms: () => { messages++; }, + }); +- let messages = 0; +- +- smpp.on('session', bound => bound.on('sms', () => { messages++; })); + + const { session } = await connect(t, smpp); + +@@ -2332,23 +2233,24 @@ describe('the server\'s onRequest hook', () => { + assert.deepEqual(seen, [], 'a bind, and everything a peer sends before one, is never the hook\'s'); + }); + +- test('passes a declined request to the sms event, and offers the keepalive and the unbind too', async t => { ++ test('passes a declined request to onSms, and offers the keepalive and the unbind too', async t => { + const seen: string[] = []; ++ const received: Sms[] = []; + const smpp = await startServer(t, { + onRequest: (_bound, pduObj) => { seen.push(pduObj.cmdName); return false; }, +- }); +- const incoming = once(resolve => { +- smpp.on('session', bound => bound.on('sms', async sms => { +- resolve(sms); ++ onSms: async sms => { ++ received.push(sms); + await sms.sendResp({ smsId: answeredId }); +- })); ++ }, + }); + const { session } = await connect(t, smpp); + + assert.ok(session); + + const answered = await submitTo(session, '46709771337', 'declined by the hook'); +- const sms = await incoming; ++ const [sms] = received; ++ ++ assert.ok(sms); + + await session.send({ cmdName: 'enquire_link' }); + await session.unbind(); +@@ -2361,15 +2263,14 @@ describe('the server\'s onRequest hook', () => { + }); + + test('reports a hook that throws and answers nothing for it', async t => { ++ let messages = 0; + const smpp = await startServer(t, { + onRequest: () => { throw new Error('the onRequest hook exploded'); }, ++ onSms: () => { messages++; }, + }); + const failed = once(resolve => { + smpp.on('session', bound => { bound.on('sessionError', resolve); }); + }); +- let messages = 0; +- +- smpp.on('session', bound => bound.on('sms', () => { messages++; })); + + const { session } = await connect(t, smpp, { responseTimeout: 300 }); + +@@ -2384,15 +2285,14 @@ describe('the server\'s onRequest hook', () => { + }); + + test('reports a hook that rejects and answers nothing for it', async t => { ++ let messages = 0; + const smpp = await startServer(t, { + onRequest: () => Promise.reject(new Error('the onRequest hook rejected')), ++ onSms: () => { messages++; }, + }); + const failed = once(resolve => { + smpp.on('session', bound => { bound.on('sessionError', resolve); }); + }); +- let messages = 0; +- +- smpp.on('session', bound => bound.on('sms', () => { messages++; })); + + const { session } = await connect(t, smpp, { responseTimeout: 300 }); + +diff --git a/test/tls.test.ts b/test/tls.test.ts +index 1e311f4..fe2daaf 100644 +--- a/test/tls.test.ts ++++ b/test/tls.test.ts +@@ -104,8 +104,9 @@ function createCertificate(): { cert: string; key: string } { + + const certificate = createCertificate(); + +-async function startServer(t: TestContext): Promise { ++async function startServer(t: TestContext, options: Parameters[0] = {}): Promise { + const { err, server: smpp } = await server({ ++ ...options, + port: 0, + tls: { cert: certificate.cert, key: certificate.key }, + }); +@@ -123,10 +124,8 @@ function once(register: (resolve: (value: T) => void) => void): Promise { + + describe('tls', () => { + test('binds over a verified handshake and delivers an SMS', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); ++ const received: Sms[] = []; ++ const smpp = await startServer(t, { onSms: sms => sms.sendResp({ smsId: 'tls-id' }).then(() => { received.push(sms); }) }); + const { err, session } = await client({ + host, + port: smpp.port, +@@ -144,15 +143,10 @@ describe('tls', () => { + assert.ok(sock.authorized); + assert.equal(sock.getPeerCertificate().subject.CN, host); + +- const [sms, sent] = await Promise.all([ +- incoming.then(async received => { +- await received.sendResp({ smsId: 'tls-id' }); +- +- return received; +- }), +- session.sendSms({ from: 'MyBrand', message: 'hello over tls', to: '46709771337' }), +- ]); ++ const sent = await session.sendSms({ from: 'MyBrand', message: 'hello over tls', to: '46709771337' }); ++ const [sms] = received; + ++ assert.ok(sms); + assert.equal(sms.message, 'hello over tls'); + assert.equal(sms.to, '46709771337'); + assert.equal(sent.err, undefined); +diff --git a/test/unsendable.test.ts b/test/unsendable.test.ts +index 3108910..5e5ad01 100644 +--- a/test/unsendable.test.ts ++++ b/test/unsendable.test.ts +@@ -56,12 +56,12 @@ describe('an alphabet the caller named that cannot carry the message', () => { + }); + + // Å is in the GSM table at 0x0E; ï is the one the encoder flattened to a space. +- test('refuses an ASCII send of a character GSM 03.38 has no code for, naming that one', async () => { ++ test('refuses a GSM7 send of a character GSM 03.38 has no code for, naming that one', async () => { + const attempts: PduObjectInput[] = []; +- const sent = await submitSms(recordingDeps(attempts), { encoding: 'ASCII', from, message: 'Åsa naïve', to }); ++ const sent = await submitSms(recordingDeps(attempts), { encoding: 'GSM7', from, message: 'Åsa naïve', to }); + + assert.ok(sent.err instanceof Error); +- assert.match(sent.err.message, /ASCII/); ++ assert.match(sent.err.message, /GSM7/); + assert.match(sent.err.message, /"ï"/); + assert.match(sent.err.message, /U\+00EF/); + assert.match(sent.err.message, /index 6/); +@@ -151,7 +151,7 @@ describe('a body the PDU\'s own data_coding cannot carry', () => { + }); + + assert.ok(built.err instanceof Error, String(dataCoding)); +- assert.match(built.err.message, /ASCII/); ++ assert.match(built.err.message, /GSM7/); + assert.match(built.err.message, /"ï"/); + assert.match(built.err.message, /U\+00EF/); + assert.match(built.err.message, /index 6/); +diff --git a/todo.md b/todo.md +index 3e5b9c7..2e12fd0 100644 +--- a/todo.md ++++ b/todo.md +@@ -25,13 +25,15 @@ const { err, session } = await client({ host, password, port, username }); + const { err: sendErr, pduObjs, smsIds } = await session.sendSms({ dlr, from, message, to }); + await session.unbind(); + +-const { err: serverErr, server: smpp } = await server({ authenticate, port }); +-smpp.on('session', session => { +- session.on('sms', async sms => { ++const { err: serverErr, server: smpp } = await server({ ++ authenticate, ++ onSms: async sms => { + await sms.sendResp(); + if (sms.dlr) await sms.sendDlr('DELIVERED'); +- }); ++ }, ++ port, + }); ++smpp.on('session', session => { /* session.userData, session.on('dlr', …) */ }); + await smpp.close(); + ``` + +@@ -268,14 +270,9 @@ below. + README.md or in MIGRATION.md — write it in the same change as the rule, so it is worded once. + From the stability and product-owner reviews of #18. + +-- [ ] **Build every error from a thrown value through `errorFrom()`.** `reconnect-loop.ts` (in +- `run()`'s catch and in `bringUp()`) and `client.ts`'s connect still call `String(thrown)`, +- which throws on a null-prototype object; in `run()` that lands as an unhandled rejection from +- a `void`ed promise, against hard rule 1. From the 2026-09-28 scoring run. +- +-- [ ] **Leave `sms.smsId` alone when `sendResp()` fails.** `sms.ts` assigns `options.smsId` before +- the lost-link check and before the write, so a failed answer still renames the message and a +- later `sendDlr()` names an id the peer was never given. From the 2026-09-28 scoring run. ++- [ ] **Build every error from a thrown value through `errorFrom()`.** `client.ts`'s connect still ++ calls `String(thrown)`, which throws on a null-prototype object, against hard rule 1. From the ++ 2026-09-28 scoring run. + + - [ ] **Refuse a timeout past 2³¹−1 ms, as `connectTimeout` already is.** `checkLimits()` bounds + `responseTimeout`, `idleTimeout`, `shutdownTimeout` and `reassemblyTimeout` from below only, +@@ -285,11 +282,6 @@ below. + so `"\x1B("` is detected as GSM, goes out as 0x1B 0x28 and arrives as `{`. From the + 2026-09-28 scoring run. + +-- [ ] **Count an `sms` listener that throws as one giving up, as a rejection already is.** A +- synchronous throw makes `Session.emit` return false, and `HeldMessages.offer()` then releases +- the hold at once, so a drain stops waiting on an async listener still answering beside it. +- Goal 2. From the stability review of #45. +- + ### Throughput — goal 6, and the default window is where we are slowest + + - [ ] **Close the gap to jsmpp at `maxOutstanding: 10`.** Measured 2026-09-20 against the same sink, diff --git a/docs/comprehension-rewrite/drafts/draft-f.patch b/docs/comprehension-rewrite/drafts/draft-f.patch new file mode 100644 index 0000000..033eca3 --- /dev/null +++ b/docs/comprehension-rewrite/drafts/draft-f.patch @@ -0,0 +1,12257 @@ +diff --git a/AGENTS.md b/AGENTS.md +index 882bb24..0a77378 100644 +--- a/AGENTS.md ++++ b/AGENTS.md +@@ -38,36 +38,37 @@ These are not preferences. Breaking one is a defect. + ``` + src/ + index.ts Public surface. Named exports only, no default export. +- client.ts client() -> { err, session } ++ client.ts client() -> { err, client }; SmppClient: one bound Session at a time, the sends waiting for one, the receipt merges + server.ts server() -> { err, server }, server owns the listener + close() +- session.ts Session: the socket's life, dispatch, events, and the collaborators below +- sms.ts The live handle emitted as the 'sms' event (sendResp/sendDlr) ++ session.ts Session: one socket's life — bound once, ended once by the socket's close, the drain, dispatch and events ++ sms.ts Sms: the handle onSms gets; sendResp() is the one place a message is answered, sendDlr() reports on it + concat.ts How a PDU says it is a segment: its UDH, or the sar_* TLVs ++ defaults.ts Every default the session layer runs on, in one object + dlr.ts Delivery receipts: text and TLV parsing, receipt status codes +- dlr-merger.ts DlrMerger: per-segment receipts counted into one MessageDlr ++ dlr-merger.ts DlrMerger: per-segment receipts counted into one MessageDlr, owned by the client + 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 ++ expiring-groups.ts ExpiringGroups: the capped, weighed, expiring store that evicts and expires on its own and reports each drop + idle-waiters.ts IdleWaiters: waiting for a count to fall to zero, and what is left of a budget +- incoming-requests.ts Every request the peer sends: messages, receipts, links, unknown commands +- link-life.ts LinkLife: whether the link lives, and where a request waits for the next one ++ incoming-requests.ts Every request the peer sends, behind a SessionPort: messages, receipts, links, unknown commands ++ link-lost-error.ts LinkLostError: the link went before the request was written, so the next one may carry it + 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: request() through the window and the pending map, end() when the socket goes + 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 ++ pdu-transport.ts PduTransport: one socket read as complete PDUs + 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 ++ reconnect-loop.ts ReconnectLoop: opens a bound Session per link on a backoff, hands out the current one, holds requests for the next + result.ts Result — the shape every fallible call returns + retained-pdu.ts A PDU copied off the wire so holding it pins nothing else, and what holding it costs ++ running-handlers.ts RunningHandlers: the onSms handlers in flight, their bound, and what a drain waits for + 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, OnSms, OnRequest, bind direction and the option checks + 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 +@@ -83,9 +84,10 @@ src/ + ``` + + Imports point one way: `defs` knows nothing above it but `result.ts`, `pdu` uses `defs`, `session` +-uses `pdu`, and `client`/`server` use `session`. The ways back up are the `Session` handed to +-`createSms()`, `HeldMessages` and `IncomingRequests`, which call back into it, and to `OnRequest` +-and `onConnected` in `session-options.ts`, all imported as a type only. ++uses `pdu`, and `client`/`server` use `session`. The ways back up are the `Session` a `Sms` carries ++and `onRequest` is handed, and `reconnect-loop`'s `Session`, all imported as a type only. What ++`IncomingRequests` may do to its session is the `SessionPort` the session builds for it, and what a ++`Sms` may do is `SmsDeps`: named functions, never the session itself. + + **Parameter order is wire order.** The key order inside `cmds.*.params` is the order the fields are + written to and read from the buffer. Never sort those alphabetically — the alphabetical-ordering +@@ -175,6 +177,8 @@ decision under [The wire](docs/decisions.md#the-wire). + collaborator the type system already keeps in step: `recordingDeps()` in `messaging-mode.test.ts`, + `message-class.test.ts` and `unsendable.test.ts` is one `SendSmsDeps.send` that answers nothing, + and a field added to that type fails to compile in every copy at once. ++- A test drives a bare `Session` through its socket: `sock.emit('data', bytes)` is a PDU arriving and ++ `sock.destroy()` the link going, so nothing reaches into the session's collaborators. + - A socket a test opens and never reads must be `resume()`d, and a `data` listener counts. An unread + socket never processes the peer's FIN, so `server.close()` hangs forever — that is a test bug, not + a library one. +@@ -277,8 +281,9 @@ this is not a changelog. + ### [The session's life](docs/decisions.md#the-sessions-life) + + - A close arriving after our own `unbind` is a clean unbind, not an error. +-- `close` means the session is over, and a drop the loop will retry is `disconnected`. +-- An answer belongs to the link the message arrived on; a receipt does not. ++- A session is one socket's life, and the client is the composition that outlives it. ++- `close` means the client is over, and a drop the loop will retry is `disconnected`. ++- A message belongs to the session it arrived on, its answer and its receipt alike. + - `reconnect` takes `{ minDelay, maxDelay }` to retune and `false` to turn off + - Coming up is not proof a link works, so only one that outlasted `maxDelay` resets the backoff. + - `reconnect: { fromStart: true }` puts the first connect and bind through that same loop, and +@@ -292,25 +297,25 @@ this is not a changelog. + application's own signal rather than the peer's answer. + - `server()` composes the application's `onRequest` after its own bind handling, and offers it every + request that handling did not answer. +-- The drain waits on the messages the application holds, and `sendResp()` is what says it is done +- with one. +-- The drain's wait on the application ignores `shutdownTimeout: 0`. +-- What the application holds unanswered is capped on constants, and a message past the cap is +- refused. ++- An inbound message is an `onSms` handler's, and the handler settling is what answers it. ++- A session with no `onSms` refuses every message with the retry status. ++- The drain waits on the `onSms` handlers still running, then on the requests on the wire, and a ++ handler running five minutes is no longer counted. ++- What the running handlers hold is capped on constants, and a message past the cap is refused. + - A store at its bound answers `ESME_RTHROTTLED` to a submission and `ESME_RX_T_APPN` to a delivery, + a `data_sm` by whichever it stands in for. +-- A reconnect keeps the delivery-receipt merges; everything else the link held is dropped. +-- The bind state is the session's, `bound()` alone writes it, and it holds through a reconnect's gap. ++- The client owns the delivery-receipt merges; everything else the link held ends with its session. ++- The bind state is the session's, and `bound()` alone writes it. + - A message id base is merged at most once. + - A send that never reached the socket waits for the next link; one that did is counted, not resent. + - A send queued for a send-window slot is bounded by the caller's `signal`, and by nothing else. +-- One owner decides whether a link can carry a request, and a bind is what makes it one. ++- The reconnect loop alone decides whether a link can carry a request, and a bound session is one. + + ### [Internals and tests](docs/decisions.md#internals-and-tests) + + - 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 `ReconnectLoop`, `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/CHANGELOG.md b/CHANGELOG.md +index 7cb5ff7..7a550a7 100644 +--- a/CHANGELOG.md ++++ b/CHANGELOG.md +@@ -2,6 +2,29 @@ + + ## 0.6.0 (unreleased) + ++- **`client()` resolves `{ err, client }`, an `SmppClient`, and a `Session` is one socket's life.** ++ The client keeps one bound session at a time (`client.session`, `undefined` while the link is ++ down, a new object after every reconnect) and carries the `dlr`, `messageDlr`, `disconnected`, ++ `reconnected`, `close` and `sessionError` events, `sendSms()`, `send()`, `unbind()` and ++ `close()`. A `Session` no longer reconnects, so `new Session()` takes no `reconnect` option and ++ emits no `disconnected`, `reconnected` or `messageDlr`; `ReconnectOptions` is gone. Its `linkEnd` ++ is a constructor option, and `session.closed` says whether its socket closed. ++- **Inbound messages go to an `onSms` option, and the `sms` event is gone.** Give it to `client()`, ++ `server()` or `Session`; `sms.session` says which session a server's message came in on. The ++ message is answered when the handler settles: `ESME_ROK` under `sms.smsId` on a bare return, the ++ `{ smsId }` or `{ status }` returned, or the retry status (`ESME_RTHROTTLED` on a submission, ++ `ESME_RX_T_APPN` on a delivery) on a throw or rejection, which also reaches `sessionError`. With ++ no `onSms` every message is refused with that retry status. `sendResp()` still answers earlier, ++ writes once, and a later `smsId` or refusing `status` is an error. `sendDlr()` before the answer ++ is refused. ++- `close()` and `unbind()` wait for `onSms` handlers still running, then for requests on the wire. ++ `shutdownTimeout: 0` waits forever for both; a handler running five minutes is no longer waited ++ for. `sendResp()` and `sendDlr()` go out during the drain. The bound of 1000 messages, or 64 MiB ++ of them, now counts handlers still running. ++- A message belongs to the session it arrived on: `sendResp()` and `sendDlr()` on one whose socket ++ closed return `err`. A receipt used to go out on the next link. ++- A drop the application closes over from `sessionError` reports `close` alone, where it used to ++ report `disconnected` first. + - `client()` now bounds each connect attempt at 10 seconds, the TLS handshake included, and reports + one that expires as an ordinary connect failure, so `reconnect` retries it on its usual backoff. + A connect previously waited the operating system out, around 130 s on Linux against a host that +diff --git a/DESIGN.md b/DESIGN.md +new file mode 100644 +index 0000000..081181a +--- /dev/null ++++ b/DESIGN.md +@@ -0,0 +1,78 @@ ++# Draft F: a session is one socket's life ++ ++## The public contract, as an implementer sees it ++ ++- `client(options)` → `{ err, client: SmppClient }`. The client is the stable handle: `sendSms()`, ++ `send()`, `unbind()`, `close()`; events `dlr`, `messageDlr`, `disconnected`, `reconnected`, ++ `close`, `sessionError`; `client.session` is the bound `Session` or `undefined` while down. ++- `server(options)` → `{ err, server: SmppServer }`, a `session` event per connection, unchanged. ++- `Session` is one socket: bound once (`bound()`), ended once when the socket closes (`close` ++ event, `closed` flag), never revived. `new Session({ sock, onSms?, onRequest?, linkEnd? })`. ++- Receiving is one option, `onSms: (sms) => Promise | …`, on client, server ++ and session. The message is answered when the handler settles: `ESME_ROK` under `sms.smsId` on ++ a bare return, the `{ smsId }`/`{ status }` returned, or the retry status on a throw. No handler ++ means every message is refused with the retry status. `sms.sendResp()` answers earlier and is ++ what `sendDlr()` requires. `answeredOnArrival` is unchanged. ++- README's "Client and session" states the shutdown in three lines and the sends-and-link rules in ++ six bullets; the glossary defines the twelve SMPP terms the README uses. ++ ++## How a drop, a reconnect and a shutdown read ++ ++- **Drop.** The socket closes → `Session.end()` runs once: timers cleared, pending requests settled ++ `UnansweredError`, queued sends settled `LinkLostError`, running handlers and half-arrived groups ++ dropped, `close` emitted. Nothing else ends a session; an idle timeout, an unframeable stream, the ++ peer's unbind and `close()` all just destroy the socket. ++- **Reconnect.** `ReconnectLoop` sees the session's `close`, tells the client (`disconnected`), ++ waits out the backoff, calls `openBoundSession()` (socket, `Session`, bind), and adopts the result ++ (`reconnected`). A client `send()` is `await links.bound()` then `session.send()`, retried only on ++ `LinkLostError` — the one error that says nothing reached the socket (goal 2). ++- **Shutdown.** `Session.close()`: `stopping = true`; wait for running handlers, then the window, ++ up to `shutdownTimeout`; destroy the socket; await its `close`. `SmppClient.close()`: stop the ++ loop, close the current session, emit `close`. Nothing re-enters anything. ++ ++## Internal structure and ownership ++ ++| Owner | State | ++| --- | --- | ++| `SmppClient` | `closed`; `ConcatReference`; `DlrMerger` (merges outlive sessions); the loop | ++| `ReconnectLoop` | current `Session`, backoff delay/timer, `upAt`, `stopped`, waiters for a link | ++| `Session` | `bind`, `stopping`, `over`, the `ended` promise; builds a `SessionPort` for `IncomingRequests` | ++| `OutgoingRequests` | `SendWindow` + `PendingRequests`; one entry point, `request()`; `end()` | ++| `IncomingRequests` | dispatch; `Reassembler`; `RunningHandlers` (count, octets, 5-min expiry) | ++| `Sms` (`sms.ts`) | `answered` + `smsId` — the one place a message is answered | ++| `ExpiringGroups` | evicts on `set()`/`weigh()` and expires on every access, reporting via `onDrop` | ++| `defaults.ts` | every default, once | ++ ++## What was deleted ++ ++`LinkLife` (phase enum, `stopped`, seven predicates, generations, held requests), `HeldMessages` ++and `MessageHold` (the `WeakMap`, listener counts, `setImmediate`, `listenerRejected`), the `sms` ++event, `Session.attach/teardown/comeBackUp/stop/emitClose/linkLost`, `PduTransport.attach()`, ++`OutgoingRequests.requestPastDrain/requestOnCurrentLink/canCarry/linkLost` and its retry loop, ++`ReconnectOptions`, `session.linkEnd` as a mutable field, `DlrMerger.close` (now `spend`), the ++`shutdownTimeout: 0` fallback rule, per-owner cap/sweep code in the three `ExpiringGroups` owners, ++and the `ascii` name on the GSM 03.38 codec object (`gsm0338`; the public option stays `ASCII`). ++ ++## Tests ++ ++`docker compose run --rm node npm test`: lint, typecheck and 7xx tests green (numbers in the ++final report). Changed tests, by file: ++ ++- `test/session-extras.test.ts` (rewritten): every `sms` listener is an `onSms`; the client result ++ is `client`; `LinkLife` unit tests → `ReconnectLoop.bound()` tests; `held message bounds` → ++ `running handler bounds` driven through a bare `Session`'s socket; `sendResp()` gains "answers ++ once" and "refuses sendDlr() until answered"; "refuses to answer a message whose link went" now ++ asserts `Socket is closed` for both answer and receipt; "drops a message whose link went while ++ onRequest was still running" drives the socket; "reports a drop once as disconnected when a ++ listener closes…" → "…as close alone"; "falls back to responseTimeout…" deleted (rule gone); ++ "still ends when the message half has spent the whole budget" holds a request via a client handler ++ that never returns; "waits for the listener still working when another one rejected" deleted (one ++ handler); "does not report a reconnect on a session closed while coming back up" and "answers a ++ peer's unbind before the session ends" rewritten without `ReconnectOptions`/`IncomingRequests` ++ internals; new `the onSms contract` describe (five tests) and `SendWindow.close()` test. ++- `test/session.test.ts`: mechanical port; `ReconnectLoop` unit tests target the new `open()` ++ API; "ignores events from the socket it left behind" → old session `closed`, new one live; ++ throwing/rejecting listener tests now assert the `ESME_RTHROTTLED` refusal. ++- `test/readme.test.ts`: mirrors the new README examples. ++- `tls`, `declared-alphabet`, `message-class`, `session-error`, `interop`, `messaging-mode`, ++ `operator-receipts`, `dummy-smsc`: mechanical port (result field, `onSms`, `MessageDlr` import). +diff --git a/MIGRATION-NOTES.md b/MIGRATION-NOTES.md +new file mode 100644 +index 0000000..accc719 +--- /dev/null ++++ b/MIGRATION-NOTES.md +@@ -0,0 +1,25 @@ ++# Migrating from 0.5.0 ++ ++Every breaking change for a 0.5.0 user, each with its replacement. ++ ++| 0.5.0 | Now | ++| --- | --- | ++| `const { err, session } = await client()` | `const { err, client: smpp } = await client()`: an `SmppClient`. `smpp.session` is the bound `Session`, `undefined` while the link is down. | ++| `session.on('sms', async sms => { await sms.sendResp(); })` on a client | `client({ onSms: async sms => { … } })`: returning answers `ESME_ROK`. | ++| `smpp.on('session', s => s.on('sms', …))` on a server | `server({ onSms: sms => { … } })`; `sms.session` is the session it came in on. | ++| A message with no `sms` listener stayed unanswered | With no `onSms` it is refused with `ESME_RTHROTTLED` (`ESME_RX_T_APPN` on a delivery). | ++| `sms.sendResp({ smsId })` / `sendResp({ status })` inside the listener | Return `{ smsId }` / `{ status }` from `onSms`, or keep calling `sendResp()` before returning. | ++| A listener that threw left the message unanswered | A handler that throws or rejects refuses the message with the retry status and reports on `sessionError`. | ++| `await sms.sendDlr()` before answering | `await sms.sendResp()` first (or report after the handler returned); `sendDlr()` on an unanswered message returns `err`. | ++| `session.on('dlr' \| 'messageDlr' \| 'disconnected' \| 'reconnected' \| 'close' \| 'sessionError', …)` on a client | The same events on the `SmppClient`. | ++| `session.on('data' \| 'incomingPdu' \| 'incomingPduObj', …)`, `session.sock`, `boundAs`, `peerInterfaceVersion`, `acceptsOptionalParams()`, `bindAllows()` on a client | On `smpp.session` (assert it is defined). It is a new object after every reconnect. | ++| `session.boundAs` held through a reconnect's gap | `smpp.session` is `undefined` in the gap; the client refuses `sendSms()` on a receiver bind from its own `bindType`. | ++| `new Session({ sock, reconnect: { connect, onConnected } })` | `new Session({ sock, onSms })`; a session ends with its socket. Open a new socket and a new `Session` to reconnect. `ReconnectOptions` is gone. | ++| `session.linkEnd = 'smsc'` on a hand-wired SMSC | `new Session({ linkEnd: 'smsc', sock })`. | ++| `sms.sendDlr()` on a message whose link dropped went out on the new link | It returns `err`; a message belongs to its session. | ++| `sendResp()` set `sms.smsId` even when the write failed; a second `sendResp()` wrote again | `sms.smsId` changes only on a successful answer; after one, a bare `sendResp()` is a no-op and an `smsId` or refusing `status` is an error. | ++| `shutdownTimeout: 0` waited `responseTimeout` for unanswered messages | `0` waits for the handlers until they return, or five minutes. | ++| `close()` reported `… N message(s) unanswered` for messages not answered | The same text now counts `onSms` handlers still running. | ++| `close()` from a `sessionError` listener during a drop reported `disconnected`, then `close` | It reports `close` alone. | ++| `session.on('sms')` with several listeners | One `onSms` per session; fan out inside it. | ++| `import type { ReconnectOptions, MessageDlr, SessionOptions } from '@larvit/smpp'` | `MessageDlr`, `SessionOptions`, `OnSms`, `ClientEvents`, `ReconnectTuning` are exported; `ReconnectOptions` is not. | +diff --git a/MIGRATION.md b/MIGRATION.md +index 0296ede..b155d00 100644 +--- a/MIGRATION.md ++++ b/MIGRATION.md +@@ -37,9 +37,12 @@ shape is the same, connect, send, listen for delivery reports, with callbacks re + Write `alert_on_message_delivery` and `broadcast_area_identifier`, the names they read back + under, for `alert_on_msg_delivery` and `failed_broadcast_area_identifier`, which are gone from + `tlvs` too. +-- **`session.loggedIn` and the `loggedIn` event are gone.** `client()` resolves once bound, +- `session.boundAs !== undefined` says a bind happened, and `disconnected`/`reconnected` say whether +- the link is up now. A session you construct yourself records a bind with `session.bound()`. ++- **`session.loggedIn` and the `loggedIn` event are gone.** `client()` resolves once bound, as ++ `{ client }`: an `SmppClient` whose `session` is the bound `Session` while the link is up, and ++ whose `disconnected`/`reconnected` events say whether it is. A session you construct yourself ++ records a bind with `session.bound()`. ++- **Inbound messages go to an `onSms` handler option**, on `client()`, `server()` and `Session`, ++ and are answered when it returns; the `sms` event is gone. `sms.sendResp()` answers earlier. + - **The `error` event is `sessionError`**, and `serverError` on the server handle. + - **`log`** takes any object with `debug`, `error`, `info`, `verbose` and `warn` methods instead of + a `larvitutils` one, and is silent by default: [README](README.md#logging). +diff --git a/README.md b/README.md +index 9ff2b7d..999ca69 100644 +--- a/README.md ++++ b/README.md +@@ -6,7 +6,8 @@ SMPP 3.4 client and server for Node.js with the session layer built in: keepaliv + window, long messages and delivery receipts. TypeScript, ESM, no dependencies. + + - **Keepalive.** `enquire_link` every 20 s on a quiet link; a peer that stops answering is dropped. +-- **Reconnect.** A dropped client link re-binds on its own, backing off from 1 s to 30 s. ++- **Reconnect.** A dropped client link is reopened and re-bound on its own, backing off from 1 s to ++ 30 s; the client handle you hold stays the same. + - **Send window.** 10 requests in flight; further sends queue instead of overrunning the SMSC. + - **Long messages.** Split on send, reassembled on receive, in both the UDH and `sar_*` spellings. + - **Delivery receipts.** Read from TLVs or from receipt text, matched to the ids you were given. +@@ -19,8 +20,9 @@ window, long messages and delivery receipts. TypeScript, ESM, no dependencies. + [Install](#install) · [Send an SMS](#send-an-sms) · [Delivery reports](#delivery-reports) · + [Receive SMS](#receive-sms) · [Run an SMPP server](#run-an-smpp-server) · [Errors](#errors) · + [Client options](#client-options) · [Server options](#server-options) · +-[Send options](#send-options) · [Session](#session) · [Receiving in depth](#receiving-in-depth) · +-[Server in depth](#server-in-depth) · [Logging](#logging) · ++[Send options](#send-options) · [Client and session](#client-and-session) · ++[Receiving in depth](#receiving-in-depth) · [Server in depth](#server-in-depth) · ++[Glossary](#glossary) · [Logging](#logging) · + [PDUs and the low-level API](#pdus-and-the-low-level-api) · + [Migrating from 0.4.0](#migrating-from-larvitsmpp-040) · [Goals](#goals) · + [Audience](#audience) · [Development](#development) +@@ -38,21 +40,23 @@ Node 18 or later. ESM only, types included. + ```javascript + import { client } from '@larvit/smpp'; + +-const { err, session } = await client(); ++const { err, client: smpp } = await client(); + if (err) throw err; + +-await session.sendSms({ ++await smpp.sendSms({ + from: '46701113311', + message: 'Hello world', + to: '46709771337', + }); + +-await session.unbind(); ++await smpp.unbind(); + ``` + + Without options this binds to `localhost:2775` as a transceiver with the default credentials. A real + SMSC needs `host`, `port`, `username` and `password`: [Client options](#client-options). A message + longer than one SMS is split and sent as one concatenated message: [Send options](#send-options). ++The handle is an `SmppClient`: it keeps one bound session at a time and opens another when the link ++drops: [Client and session](#client-and-session). + + ## Delivery reports + +@@ -64,7 +68,7 @@ import { client } from '@larvit/smpp'; + + const log = new Log('debug'); + +-const { err, session } = await client({ ++const { err, client: smpp } = await client({ + host: 'smpp.somewhere.com', + log, + password: 'bar', +@@ -73,11 +77,11 @@ const { err, session } = await client({ + }); + if (err) throw err; + +-session.on('dlr', dlr => { ++smpp.on('dlr', dlr => { + // dlr.smsId, dlr.statusMsg, dlr.statusId + }); + +-const { err: sendErr, smsIds } = await session.sendSms({ ++const { err: sendErr, smsIds } = await smpp.sendSms({ + dlr: true, + from: '46701113311', + message: '«baff»', +@@ -92,34 +96,32 @@ receipts into one, and SMSCs that write ids in two notations: [Delivery receipts + + ## Receive SMS + +-A `receiver` or `transceiver` client gets mobile-originated messages as `sms` events: ++A `receiver` or `transceiver` client hands each mobile-originated message to its `onSms` handler: + + ```javascript +-session.on('sms', async sms => { +- // sms.from, sms.to, sms.message +- await sms.sendResp(); ++const { err, client: smpp } = await client({ ++ onSms: async sms => { ++ // sms.from, sms.to, sms.message ++ }, + }); + ``` + +-Call `sendResp()` for every message, multipart included: until you do, it counts toward the bound +-past which the peer's messages are refused. Delivery receipts reach you as `dlr` events, not here. A +-multipart message arrives reassembled and already answered segment by segment, so `sendResp()` there +-puts nothing on the wire and releases it: [Receiving in depth](#receiving-in-depth). ++Returning answers the message `ESME_ROK`; throwing refuses it, and the SMSC sends it again. Return ++`{ smsId }` to name the id, or `{ status: 'ESME_RMSGQFUL' }` to refuse it yourself. Delivery ++receipts reach `dlr`, not here. A multipart message arrives reassembled, already answered segment by ++segment: [Receiving in depth](#receiving-in-depth). + + ## Run an SMPP server + + ```javascript + import { server } from '@larvit/smpp'; + +-const { err, server: smpp } = await server(); +-if (err) throw err; +- +-smpp.on('session', session => { +- session.on('sms', async sms => { +- // sms.from, sms.to, sms.message, sms.dlr +- await sms.sendResp(); +- }); ++const { err, server: smpp } = await server({ ++ onSms: async sms => { ++ // sms.from, sms.to, sms.message, sms.dlr; returning answers ESME_ROK ++ }, + }); ++if (err) throw err; + ``` + + With authentication and delivery reports: +@@ -134,63 +136,61 @@ const { err, server: smpp } = await server({ + + return { userData: { userId: 123 } }; + }, +-}); +-if (err) throw err; +- +-smpp.on('session', session => { +- session.on('sms', async sms => { +- if (sms.answeredOnArrival) { +- await sms.sendResp(); // multipart: already answered per segment; this only releases the shutdown drain +- } else { +- // no args: ESME_ROK + generated id; or sendResp({ smsId, status: 'ESME_RMSGQFUL' }) +- await sms.sendResp(); +- } ++ onSms: async sms => { ++ // Answer now rather than on return, since a receipt can only follow the answer. ++ await sms.sendResp(); // ESME_ROK + generated id; or sendResp({ smsId }) / sendResp({ status: 'ESME_RMSGQFUL' }) + + if (sms.dlr) { + await sms.sendDlr(); // same as sms.sendDlr('DELIVERED') + } +- }); ++ }, + }); ++if (err) throw err; + + console.log(smpp.port); // the port actually bound, useful when 0 was requested + await smpp.close(); // stop listening, then drain and close every live session + ``` + +-- `sendResp()` answers `ESME_ROK` with a generated UUID v7 as the message id. +- `sendResp({ smsId, status })` names the id or refuses the message. ++- One `onSms` serves every session the server accepts; `sms.session` is the one a message came in ++ on, and `session.userData` is what `authenticate` attached. ++- Returning from `onSms` answers `ESME_ROK` with a generated UUID v7 as the message id, or with the ++ `{ smsId }` or `{ status }` returned. `sendResp()` answers earlier, for a handler that goes on ++ working after the answer. + - `sms.dlr` is true where the sender asked for a receipt. `sendDlr()` reports `DELIVERED`, +- `sendDlr('UNDELIVERABLE')` any other state: [Server in depth](#server-in-depth). +-- A message that arrived in several segments was answered as they arrived, so `sendResp()` there +- takes no `smsId` or refusing `status`. `sms.answeredOnArrival` says which case you are in. ++ `sendDlr('UNDELIVERABLE')` any other state, and is refused until the message is answered: ++ [Server in depth](#server-in-depth). ++- A message that arrived in several segments was answered as they arrived, so neither the return ++ nor `sendResp()` may name an `smsId` or a refusing `status` for it. `sms.answeredOnArrival` says ++ which case you are in. + + ## Errors + + Nothing throws. Every fallible call returns a result with an optional `err`: + + ```javascript +-const { err, session } = await client({ host: 'smpp.somewhere.com' }); ++const { err, client: smpp } = await client({ host: 'smpp.somewhere.com' }); + if (err) return; + +-const { err: sendErr, smsIds } = await session.sendSms({ from, message, to }); ++const { err: sendErr, smsIds } = await smpp.sendSms({ from, message, to }); + ``` + +-Failures on a live session arrive as `sessionError` events, on a server handle as `serverError`. +-Neither is named `error`, because Node throws on an unhandled `error` event. ++Failures on a live client or session arrive as `sessionError` events, on a server handle as ++`serverError`. Neither is named `error`, because Node throws on an unhandled `error` event. + + `sessionError` carries three kinds of failure: + + | Kind | Type | | + | --- | --- | --- | + | A PDU the peer sent that the codec could not read. The link stays up; only that PDU is lost. | `PduRefusedError` | Count it. | +-| A concatenated message given up on before it was whole. Its segments were answered, so the peer will not resend, and no `sms` fired for it. | `Error` | Count it as lost traffic. | +-| The session or socket failing, or a hook or listener that threw or rejected. | `Error` | Alert. | ++| A concatenated message given up on before it was whole, or one `onSms` failed on after its segments were answered. The peer will not resend it. | `Error` | Count it as lost traffic. | ++| The session or socket failing, or a hook, handler or listener that threw or rejected. | `Error` | Alert. | + + The last two are told apart by message text only, so this alerts on both: + + ```javascript + import { PduRefusedError } from '@larvit/smpp'; + +-session.on('sessionError', err => { ++smpp.on('sessionError', err => { + if (err instanceof PduRefusedError) { + log.warn('the peer sent a PDU that could not be read', { + cmdName: err.header.cmdName ?? err.header.cmdId, +@@ -209,7 +209,7 @@ session.on('sessionError', err => { + `seqNr`. `cmdName` is undefined for a command id this library does not know. `PduHeader` is its type. + - A refused request is answered with the status SMPP names for it. A refused response is answered + with nothing, and settles the request it named as `unanswered`. +-- A refused inbound `deliver_sm` is lost traffic: a message or receipt that never arrives as `sms` ++- A refused inbound `deliver_sm` is lost traffic: a message or receipt that never reaches `onSms` + or `dlr`. A refused response is reported twice, as the `err` of the `sendSms()` or `send()` + waiting on it and here. + - A `PduRefusedError` is always a PDU that arrived. What this library refuses to build or send (an +@@ -231,20 +231,22 @@ All optional. Timeouts and delays are milliseconds. + | `enquireLinkInterval` | `20000` | Interval between `enquire_link` on a quiet link. | + | `idleTimeout` | `2 × enquireLinkInterval` | Give up on a link the peer has stopped answering, and re-bind unless `reconnect` is `false`. | + | `responseTimeout` | `30000` | How long to wait for a response, and how long a send with no link waits for the next one. `0` waits forever. | +-| `shutdownTimeout` | `5000` | How long `close()` and `unbind()` wait for requests already sent and messages not yet answered. `0` waits forever for the requests, which end when the peer answers or `responseTimeout` expires, so both at `0` never ends. The messages then fall back to `responseTimeout`, or to its default where that is `0` too. | ++| `shutdownTimeout` | `5000` | How long `close()` and `unbind()` wait for `onSms` handlers still running and requests already sent. `0` waits forever: a request ends when the peer answers or `responseTimeout` expires, a handler when it returns or five minutes pass. | + | `maxOutstanding` | `10` | Requests on the wire at once; further sends queue. | ++| `onSms` | none | `(sms) => Promise \| SendRespOptions \| void`: every mobile-originated message. Without one the SMSC's deliveries are refused: [Receive SMS](#receive-sms). | + | `smsIdFormat` | — | The notation the SMSC writes message ids in, per place: `{ receipt: 'decimal', submitResp: 'hex' }`. Only where the two disagree: [Delivery receipts](#delivery-receipts). | +-| `reconnect` | on | `{ minDelay, maxDelay }` retunes the backoff; `false` turns it off, so a drop ends the session; `{ fromStart: true }` retries the first connect too. | ++| `reconnect` | on | `{ minDelay, maxDelay }` retunes the backoff; `false` turns it off, so a drop ends the client; `{ fromStart: true }` retries the first connect too. | + | `log` | silent | Any object with `debug`, `error`, `info`, `verbose` and `warn` methods: [Logging](#logging). | +-| `signal` | — | An `AbortSignal` that cancels connecting and tears the session down. | ++| `signal` | — | An `AbortSignal` that cancels connecting and closes the client. | + + **Reconnect.** After a drop, an idle timeout, or a stream the library cannot frame, the client +-reopens the socket and re-binds, doubling the delay from `minDelay` (1 s) to `maxDelay` (30 s), and +-starts over at `minDelay` once a link has lasted `maxDelay`. ++opens a new socket, binds a new session on it, and announces it with `reconnected`, doubling the ++delay from `minDelay` (1 s) to `maxDelay` (30 s), and starting over at `minDelay` once a link has ++lasted `maxDelay`. + + `reconnect: { fromStart: true }` puts the first connect and bind through the same loop, a bind the + SMSC refuses included, so a client started while its SMSC is down keeps retrying. `client()` then +-resolves once bound, and only an aborted `signal` ends the wait. That signal also closes the session ++resolves once bound, and only an aborted `signal` ends the wait. That signal also closes the client + once bound, so write a deadline as an `AbortController` you stop arming when `client()` returns, + not as `AbortSignal.timeout(ms)`. + +@@ -257,6 +259,7 @@ All optional. Timeouts are milliseconds. + | `host`, `port` | all interfaces, `2775` | Where to listen. `port: 0` takes any free port; `smpp.port` says which. | + | `authenticate` | accept everything | `({ password, session, systemId, systemType }) => false \| { userData }`, sync or async. | + | `onRequest` | none | `(session, pduObj) => true \| false`, sync or async. First refusal on every request a bound peer sends: [Server in depth](#server-in-depth). | ++| `onSms` | none | Every message a bound peer submits, on any session: [Run an SMPP server](#run-an-smpp-server). Without one, submissions are refused with `ESME_RTHROTTLED`. | + | `systemId` | `''` | The SMSC identity returned in the bind response. | + | `interfaceVersion` | `0x34` | The SMPP version advertised in the bind response. Optional parameters are sent to a peer from `0x34` up, whatever this is set to. | + | `tls` | `false` | A `tls.TlsOptions` object with your certificate and key. A bare `true` is refused. | +@@ -269,7 +272,7 @@ All optional. Timeouts are milliseconds. + ## Send options + + ```javascript +-await session.sendSms({ ++await smpp.sendSms({ + dlr: true, // ask for a delivery report + destinationAddrNpi: 0, // override the numbering plan of the recipient + destinationAddrTon: 1, +@@ -309,14 +312,14 @@ refuse a non-ASCII sender of its own accord, which reaches you as a refusal such + - An alphabet you name has to carry every character, or the send is refused before anything goes + out, naming the character, its code point and its index. Detection never refuses. + - `LATIN1` carries every octet, so `buffer.toString('latin1')` reaches the SMSC byte for byte, under +- `data_coding` 0x03, which declares Latin-1 text. To declare 8-bit binary, hand `session.send()` a ++ `data_coding` 0x03, which declares Latin-1 text. To declare 8-bit binary, hand `send()` a + `Buffer` body and the `data_coding` you want: [PDUs and the low-level API](#pdus-and-the-low-level-api). + - `consts.ENCODING` is the low-level `data_coding` table, not this option's list. + + **Long messages.** + + ```javascript +-const { err, pduObjs, smsIds, unanswered } = await session.sendSms({ from, message, to }); ++const { err, pduObjs, smsIds, unanswered } = await smpp.sendSms({ from, message, to }); + ``` + + - One id per segment. `smsIds` is positional with `pduObjs`, and an entry is `undefined` where the +@@ -351,54 +354,67 @@ Name a later instant as a `Date`, which goes out absolute. + an alphabet or a time you named, a string body under a `data_coding` you named. What you formed + yourself, a `Buffer` body or a stamp you formatted, passes through as written, except that a text + field is still checked: [PDUs and the low-level API](#pdus-and-the-low-level-api). The same rule +-holds for `session.send()`. ++holds for `send()`. ++ ++## Client and session + +-## Session ++A **session** is one socket's life: bound once, ended once when the socket closes, never revived. ++`server()` hands you one per connection. A **client** is the stable handle `client()` returns: it ++holds one bound session at a time, opens another when the link drops, and carries what outlives a ++session — the sends waiting for a link and the receipt merges. `smpp.session` is the current one, ++`undefined` while the link is down, and a different object after every reconnect. + +-### Events ++### Client events + + | Event | Fires when | + | --- | --- | +-| `sms` | An SMS arrives, reassembled if it was multipart. Carries `sendResp()`, `sendDlr()` and `smsId`. | + | `dlr` | A delivery report arrives, one per segment, with its PDU as the second argument: [Delivery receipts](#delivery-receipts). | + | `messageDlr` | Every segment of a long message sent with `dlr: true` has a final report: [Delivery receipts](#delivery-receipts). | +-| `close` | The session is over and nothing will bring the link back. Fires once, whether you closed it or the link failed for good. | +-| `disconnected` | The link dropped and the reconnect loop will retry. Do not open a replacement client: this session comes back on its own, and `reconnected` says when. Fires again for each attempt that reconnects and then fails, so it is not one-to-one with `reconnected`. | +-| `reconnected` | The client re-bound after a drop. | +-| `sessionError` | Something failed on a live session, a PDU the codec refused included: [Errors](#errors). | ++| `disconnected` | The session ended and the client will open another. Do not open a replacement client: `reconnected` says when it is back. | ++| `reconnected` | A new session is bound after a drop. | ++| `close` | The client is over: you closed it, `reconnect` is `false` and the link dropped, or the `signal` aborted. Fires once. | ++| `sessionError` | Something failed on the current session, a PDU the codec refused included: [Errors](#errors). | ++ ++### Session events ++ ++| Event | Fires when | ++| --- | --- | ++| `dlr` | As on the client, for this session's receipts. | ++| `close` | The socket closed. Fires once. | ++| `sessionError` | As on the client. | + | `data` | Raw bytes arrived on the socket. | + | `incomingPdu` | A complete PDU arrived, as a buffer. | + | `incomingPduObj` | The same PDU, parsed into an object. | + + ### Methods + +-`sendSms()`, `send()`, `sendReturn()`, `unbind()` and `close()`. ++Client and session alike: `sendSms()`, `send()`, `unbind()` and `close()`. Session alone: ++`sendReturn()`, `bound()`, `bindAllows()`, `acceptsOptionalParams()`, `boundAs`, ++`peerInterfaceVersion`, `sock`, `closed`, `linkEnd`, `userData`. + + **Shutdown.** `close()` and `unbind()` both: + +-1. Refuse further sends. `sendDlr()` is the one send let past, when issued straight after +- `sendResp()`; await anything in between and it races the shutdown like any other send. +-2. Wait up to `shutdownTimeout` for the requests already sent, and for every `sms` the application +- has not answered. That wait ends when `sendResp()` puts the response on the wire (or, for a +- message answered on arrival, when it is called at all), or when every listener that took the +- message has failed. Answering through `sendReturn()` instead leaves the wait running. +-3. Tear down what is left, resolving to an `err` that says what was lost. ++1. Refuse further `sendSms()` and `send()` calls. `sendResp()` and `sendDlr()` still go out, since ++ answering and reporting are what the drain waits for. ++2. Wait up to `shutdownTimeout` for every `onSms` handler still running, then for the requests ++ already sent. ++3. Destroy the socket, resolving to an `err` that says what was lost. + +-A message left unanswered for five minutes is no longer waited for. +-`close({ signal })` cuts the wait short. `unbind()` takes no signal, and waits a further +-`responseTimeout` for its own response. ++A handler running five minutes is no longer waited for. `close({ signal })` cuts the wait short. ++`unbind()` takes no signal, and waits a further `responseTimeout` for its own response. On the ++client both also stop the reconnect loop; a client whose link is down closes at once. + + **Sends and the link.** + +-- A send issued while the link is down waits for the reconnect and goes out once the new link is +- bound, up to `responseTimeout`, after which it gives up having sent nothing. ++- A client send issued while the link is down waits for the next session and goes out once it is ++ bound, up to `responseTimeout`, after which it gives up having sent nothing. One that was still ++ queued behind a full window when the link dropped goes out on the next session too. + - A request already on the wire when the link drops, when the peer fails to answer in time, or when + you abort it, fails and counts in `unanswered`: the SMSC may have taken it and lost only the + response. +-- With `reconnect: false` a drop ends the session, and every send after it is refused. +-- `sms.sendResp()` on a message whose link dropped writes nothing and returns `err`, since a response +- carries the sequence number of the link it arrived on. `sms.sendDlr()` still goes out on the new +- link. ++- With `reconnect: false` a drop ends the client, and every send after it is refused. ++- A message belongs to the session it arrived on. Once that socket closed, `sms.sendResp()` and ++ `sms.sendDlr()` return `err`. + - `responseTimeout` bounds the wait for a link and the wait for an answer separately, and the wait + for a `maxOutstanding` slot is unbounded, so it is not a deadline. For a deadline pass + `{ signal: AbortSignal.timeout(ms) }`: it cuts all three waits short, and a send it stops before +@@ -410,29 +426,28 @@ A message left unanswered for five minutes is no longer waited for. + **Raw commands.** `send()` reaches all 33 SMPP commands, not just the four the session handles itself: + + ```javascript +-const { err, pduObj } = await session.send({ ++const { err, pduObj } = await smpp.send({ + cmdName: 'query_sm', + params: { message_id: smsId }, + }); + ``` + +-**The peer.** ++**The peer**, on a session: + + - `acceptsOptionalParams()`: whether the peer declared SMPP 3.4 or later, the version from which + optional parameters may be sent to it. The library's own senders check it before attaching a TLV; + a `send()` you build is passed through as written, so check it yourself. + - `peerInterfaceVersion`: the version the peer declared, `0x00` if none, `undefined` before any bind. + - `bindAllows(cmdName)` and `boundAs`: what the bind direction carries: [Bind direction](#bind-direction). +-- `boundAs` and `peerInterfaceVersion` are read-only, and hold through a reconnect's gap until the +- link binds again. + - `bound(bindType, declaredVersion)`: how a session you construct yourself records a bind, whichever +- end accepted it, on every link it binds. `bindType` is `receiver`, `transceiver` or `transmitter`; +- `declaredVersion` is 0-255, or `undefined` where the peer declared none. Anything else returns `err` +- and records nothing. +-- An ESME wired by hand sends its own `bind_` through `session.send()`, after it is +- constructed and again in `reconnect.onConnected`, and records each accepted one with ++ end accepted it. `bindType` is `receiver`, `transceiver` or `transmitter`; `declaredVersion` is ++ 0-255, or `undefined` where the peer declared none. Anything else returns `err` and records ++ nothing. ++- An ESME wired by hand (`new Session({ sock, onSms })`) sends its own `bind_` through ++ `session.send()` and records the accepted one with + `session.bound(bindType, pduObj.tlvs.sc_interface_version?.tagValue)`, where `bindType` is the one +- it sent and `pduObj` the `bind_resp` that `send()` resolved with. ++ it sent and `pduObj` the `bind_resp` that `send()` resolved with. A session reconnects nothing: ++ open a new socket and a new `Session` when it closes. + + ## Receiving in depth + +@@ -442,18 +457,26 @@ const { err, pduObj } = await session.send({ + each is two messages. + - **Answered on arrival.** Each segment was answered as it landed, before you see the message: + [Server in depth](#server-in-depth). +-- **Unanswered messages.** While a session holds 1000 messages you have not called `sendResp()` on, +- or 64 MiB of them counted the way `maxOctets` counts segments, every new message and segment is +- refused so the peer retries it: `ESME_RTHROTTLED` on a submission, `ESME_RX_T_APPN` on a delivery. +- No `sms` fires. Reaching the bound logs one `warn`, and the first message accepted once both are +- down to half one `info`. A message left five minutes is dropped from the count with a `warn`; a +- later `sendResp()` still answers it. None of the three is an option. ++- **The handler.** `onSms` runs once per message and the message is answered when it settles: ++ `ESME_ROK` under `sms.smsId` on a bare return, the `{ smsId }` or `{ status }` returned, or the ++ retry status (`ESME_RTHROTTLED` on a submission, `ESME_RX_T_APPN` on a delivery) on a throw or a ++ rejection, which also reaches `sessionError`. `sendResp()` answers earlier and is what `sendDlr()` ++ waits for; after an answer, a returned `{ smsId }` or refusing `{ status }` is an error on ++ `sessionError` and a bare return is nothing. A message answered on arrival that the handler fails ++ on is lost traffic, reported on `sessionError`. ++- **No handler.** With no `onSms`, every message is refused with that retry status at once, so the ++ peer keeps it. ++- **Handlers still running.** While 1000 handlers have not returned, or 64 MiB of their messages ++ counted the way `maxOctets` counts segments, every new message and segment is refused with the ++ retry status. Reaching the bound logs one `warn`, and the first message accepted once both are ++ down to half one `info`. A handler running five minutes is no longer counted, with a `warn`. None ++ of the three is an option. + - **Where the body is.** A body in the `message_payload` TLV, SMPP's way of carrying up to 64 KB and + the only place a `data_sm` has, reads exactly like one in `short_message`, concatenated messages + and receipts included. A PDU filling both is read from `short_message`. +-- **`data_sm`.** A client reads an inbound `data_sm` as a delivery: a message arrives as `sms`, a +- receipt as `dlr`. A `server()` session reads it as a submission and always emits `sms`. Either way +- it is answered `data_sm_resp`. ++- **`data_sm`.** A client reads an inbound `data_sm` as a delivery: a message goes to `onSms`, a ++ receipt to `dlr`. A `server()` session reads it as a submission and always hands it to `onSms`. ++ Either way it is answered `data_sm_resp`. + - **Flash.** `sms.flash` is true where `data_coding` carries GSM 03.38 message class 0, in every + coding group that carries one: `0x10`, `0x18`, `0x50` and `0xF0` alike. Classes 1 to 3 name where + the handset stores the message and are not flash. +@@ -483,7 +506,7 @@ decimal `id:` in the receipt, or one of them zero-padded, and the comparison the + Name each notation and both are read into plain decimal: + + ```javascript +-const { err, session } = await client({ smsIdFormat: { receipt: 'decimal', submitResp: 'hex' } }); ++const { err, client: smpp } = await client({ smsIdFormat: { receipt: 'decimal', submitResp: 'hex' } }); + ``` + + `receipt` is the notation of the body's `id:`; `submitResp` that of the `message_id` in +@@ -504,12 +527,12 @@ even where the SMSC took some of its segments; their receipts still arrive as `d + **Multipart is answered on arrival.** Each segment is answered as it lands, because a relaying SMSC + will not send the next until the last is answered. The answer is `ESME_ROK`, unless the segment + numbers itself into no message this session can join, which refuses it, or the reassembly buffer or +-the unanswered messages are at their bound, which asks the SMSC to keep it and try again. ++the handlers still running are at their bound, which asks the SMSC to keep it and try again. + `sms.answeredOnArrival` says whether the message you hold was answered that way; a segment count + cannot, since a peer may number a message one part of one. + +-- The id was fixed with the first segment, so `sendResp()` there puts nothing on the wire and +- releases the message, and returns `err` for an `smsId` or a refusing `status`. ++- The id was fixed with the first segment, so `sendResp()` there puts nothing on the wire, and both ++ it and the handler's return refuse an `smsId` or a refusing `status`. + - `sms.smsId` is the base. `sendDlr()` names `-1`, `-2` and so on: the ids the + `submit_sm` responses carried. + - A `deliver_sm` is answered with no id at all, since SMPP marks that field unused, so an inbound +@@ -517,7 +540,7 @@ cannot, since a peer may number a message one part of one. + + **Refusing a request** for a reason in the request rather than the message (a full queue, an unknown + recipient, an unauthorised sender) has to land before a segment is answered. `onRequest` runs on +-every request a bound peer sends, before reassembly and before the `sms` event: ++every request a bound peer sends, before reassembly and before `onSms`: + + ```javascript + import { isCommand, server } from '@larvit/smpp'; +@@ -555,21 +578,42 @@ if (err) throw err; + + **`sendDlr()`** takes `SCHEDULED`, `ENROUTE`, `DELIVERED`, `EXPIRED`, `DELETED`, `UNDELIVERABLE`, + `ACCEPTED`, `UNKNOWN`, `REJECTED` or `SKIPPED`. The first two go out as intermediate delivery +-notifications (`esm_class` 0x20), the rest as delivery receipts (0x04). ++notifications (`esm_class` 0x20), the rest as delivery receipts (0x04). A receipt names a message ++the peer had accepted, so before the message is answered it returns `err`: call `sendResp()` first ++inside the handler, or keep `sms` and report after the handler returned. + + ### Bind direction + + The three bind types are honoured in both directions, whichever end of the link the session is: + +-- `session.sendSms()` on a receiver-bound session, and `sms.sendDlr()` to a transmitter-bound peer, ++- `sendSms()` on a receiver-bound client or session, and `sms.sendDlr()` to a transmitter-bound peer, + return `err` before anything reaches the wire. + - A `submit_sm` arriving on a receiver-bound session, or a `deliver_sm` on a transmitter-bound one, + is answered `ESME_RINVBNDSTS`. + - `data_sm` carries a message either way, so which end the session is decides: a client refuses one + on a transmitter bind, a `server()` session on a receiver bind. `bindAllows('data_sm')` answers for + the inbound direction. A `Session` you construct yourself is the ESME end, as `client()` builds; +- a hand-wired SMSC sets `session.linkEnd = 'smsc'`, as `server()` does. +-- `transceiver`, the default, carries both. `session.send()` is a passthrough and is not checked. ++ a hand-wired SMSC passes `linkEnd: 'smsc'`, as `server()` does. ++- `transceiver`, the default, carries both. `send()` is a passthrough and is not checked. ++ ++## Glossary ++ ++The SMPP terms this README uses, as SMPP 3.4 uses them. ++ ++| Term | | ++| --- | --- | ++| ESME | External Short Message Entity: the application end of a link, the one `client()` builds. | ++| SMSC, MC | Short Message Service Centre, or Message Centre: the operator's end, the one `server()` stands in for. | ++| bind | The login that opens an SMPP session on a socket: `bind_transmitter`, `bind_receiver` or `bind_transceiver`, naming which way messages may travel. | ++| PDU | Protocol Data Unit: one SMPP command or response on the wire, a 16-octet header and a body. | ++| `submit_sm`, `deliver_sm` | The message-carrying commands: an ESME submits, an SMSC delivers. `data_sm` carries a message either way. | ++| DLR | Delivery report, or delivery receipt: the SMSC's word about a message you submitted, carried on `deliver_sm`. | ++| `esm_class` | The octet that says what kind of PDU a message is: a receipt, a notification, a segment with a UDH. | ++| `data_coding` | The octet that names the alphabet a body is written in, and a message class beside it. | ++| UDH | User Data Header: octets at the start of a body that number a long message's segments. | ++| `sar_*` | The TLVs that number segments where a UDH does not: `sar_msg_ref_num`, `sar_total_segments`, `sar_segment_seqnum`. | ++| TLV | Tag-Length-Value: an optional parameter after a PDU's body, SMPP 3.4 and later. | ++| send window | How many requests may be on the wire unanswered at once: `maxOutstanding`. | + + ## Logging + +@@ -583,7 +627,7 @@ static; every dynamic value is in the metadata, so entries group by message. + import { Log } from '@larvit/log'; + import { client } from '@larvit/smpp'; + +-const { err, session } = await client({ log: new Log('debug') }); ++const { err, client: smpp } = await client({ log: new Log('debug') }); + ``` + + So does an object of your own: +@@ -650,7 +694,7 @@ if (isCommand(pduObj, 'submit_sm')) { + array is refused. + - A `Buffer` goes out exactly as given under any `data_coding`: binary payloads, hand-built user + data headers, deliberately malformed bodies. +-- `session.send()` and `session.sendReturn()` build through the same codec and refuse the same bodies. ++- `send()` and `session.sendReturn()` build through the same codec and refuse the same bodies. + - `unencodable(message, encoding)`: `{ char, index }` for the first character an alphabet cannot + carry, `undefined` where it carries them all. The check `sendSms()` makes before encoding. + - `dataCodingByEncoding[encoding]`: the `data_coding` this library writes each alphabet under, which +@@ -662,13 +706,13 @@ if (isCommand(pduObj, 'submit_sm')) { + + | | | + | --- | --- | +-| Sessions | `client`, `server`, `Session`, `SmppServer` | ++| Sessions | `client`, `server`, `SmppClient`, `SmppServer`, `Session` | + | Codec | `pduToObj`, `objToPdu`, `pduReturn`, `isCommand`, `isResp`, `PduFramer`, `PduRefusedError`, `maxPduLength`, `maxSeqNr` | + | Messages | `encodeMessage`, `decodeMessage`, `splitMessage`, `bitCount`, `messageOctets`, `concatOf`, `concatInfo`, `detect`, `unencodable`, `messageClassOf`, `dataCodingByEncoding`, `encodingByDataCoding` | + | Receipts | `dlrFromPdu`, `parseReceipt`, `receiptCodes` | + | Time and ids | `smppDate`, `smppTime`, `uuidv7` | + | Spec tables | `cmds`, `consts`, `encodings`, `errors`, `tlvs`, `types`, the `cmdsById`, `constsById`, `errorsById` and `tlvsById` maps, and all of them grouped as `defs`. `isCommandName`, `isErrorName`, `isEncodingName`, `isTlvName`, `commandNameById` and `errorNameById` narrow a value into them. | +-| Types | Every option, result, event payload and table entry has a named type: `ClientOptions`, `ServerOptions`, `SendSmsOptions`, `SendSmsResult`, `Sms`, `Dlr`, `MessageDlr`, `Receipt`, `PduObject`, `PduHeader`, `SmppLog`, `Result` and the rest in `dist/index.d.ts`. | ++| Types | Every option, result, event payload and table entry has a named type: `ClientOptions`, `ClientEvents`, `ServerOptions`, `SessionOptions`, `OnSms`, `SendSmsOptions`, `SendSmsResult`, `SendRespOptions`, `Sms`, `Dlr`, `MessageDlr`, `Receipt`, `PduObject`, `PduHeader`, `SmppLog`, `Result` and the rest in `dist/index.d.ts`. | + + ## What changed per release + +diff --git a/benchmarks/smsc-sink.ts b/benchmarks/smsc-sink.ts +index 324c11e..deb47c0 100644 +--- a/benchmarks/smsc-sink.ts ++++ b/benchmarks/smsc-sink.ts +@@ -5,21 +5,14 @@ import { server } from '../src/server.ts'; + * library's own ceiling rather than an SMSC's storage. Prints the bound port on stdout, then waits. + */ + const port = Number(process.env.PORT ?? 0); +-const { err, server: smpp } = await server({ port }); ++let answered = 0; ++const { err, server: smpp } = await server({ onSms: () => { answered++; }, port }); + + if (err) { + process.stderr.write(`sink failed to listen: ${err.message}\n`); + process.exit(1); + } + +-let answered = 0; +- +-smpp.on('session', session => { +- session.on('sms', async sms => { +- answered++; +- await sms.sendResp(); +- }); +-}); + + smpp.on('serverError', reason => { + process.stderr.write(`sink serverError: ${reason.message}\n`); +diff --git a/benchmarks/submit-load.ts b/benchmarks/submit-load.ts +index 95de428..85f3c57 100644 +--- a/benchmarks/submit-load.ts ++++ b/benchmarks/submit-load.ts +@@ -19,7 +19,7 @@ const dlr = process.argv.includes('--dlr'); + const username = arg('username', 'user'); + const password = arg('password', 'pass'); + +-const { err, session } = await client({ ++const { err, client: esme } = await client({ + host, + maxOutstanding: concurrency, + password, +@@ -34,7 +34,7 @@ if (err) { + } + + // Narrowing from the guard above does not reach into worker(), which runs after it. +-const bound = session; ++const bound = esme; + + let issued = 0; + let failed = 0; +diff --git a/docs/decisions.md b/docs/decisions.md +index c245097..ecf9f5b 100644 +--- a/docs/decisions.md ++++ b/docs/decisions.md +@@ -499,19 +499,42 @@ rule and an index of the titles below. + + ## The session's life + ++- **A session is one socket's life, and the client is the composition that outlives it.** ++ Maintainer's call, 2026-09-29, from the third comprehension round: every reader panel named the ++ session lifecycle as the unit it least wanted to touch — a phase enum with a separate stopped ++ flag, seven predicates over it read by three collaborators, a generation counter, requests held ++ for a link that may never come, and a drain spanning links. Serves goal 8 (reshapeable internals a ++ reader can hold) and goal 5 (the session layer, on its defaults). A `Session` is bound once, ++ ends once when its socket closes, and is never revived: `end()` runs from the socket's `close` ++ event and nothing else, so close(), unbind(), the idle timeout, an unframeable stream and the ++ peer's unbind all reach it the same way, by destroying the socket. Reconnect is `SmppClient`, a ++ composition above it: `ReconnectLoop` opens a bound session per link on the backoff and hands out ++ the current one, the client holds the concat reference and the receipt merges, and a send waits ++ in the client for a link and is retried there only on `LinkLostError`, the one error that says ++ nothing reached the socket. Rejected: one state machine inside the session (round two's draft B), ++ which organised the complexity where this removes it. Rejected: a stream of sessions handed to ++ the application, which makes goal 5's reconnect the application's to wire. Accepted: `messageDlr` ++ is a client event, so a hand-wired `Session` merges nothing; and a message belongs to its ++ session, below. Valid while goal 5 keeps reconnect inside the library. ++ + - **A close arriving after our own `unbind` is a clean unbind, not an error.** Maintainer's call, + 2026-08-26: most SMSCs drop the socket instead of answering, so the documented shutdown would + otherwise always report a failure. It does mask a socket that died mid-unbind for an unrelated + reason, which is accepted — the peer sees the same TCP close either way. + +-- **`close` means the session is over, and a drop the loop will retry is `disconnected`.** ++- **`close` means the client is over, and a drop the loop will retry is `disconnected`.** + Maintainer's call, 2026-08-31: without the split, an application that opens a replacement client on +- `close` ends up holding two binds on one account, which goal 4 forbids. +- +-- **An answer belongs to the link the message arrived on; a receipt does not.** Maintainer's call, +- 2026-09-01. Rejected: answering on the new link, which succeeds and reports `{}` for a response +- that correlates with nothing — goal 2's wrong answer. Accepted: a receipt sent after a refused +- response names an id the peer has no record of. ++ `close` ends up holding two binds on one account, which goal 4 forbids. A `close()` called from a ++ `sessionError` listener during a drop stops the loop before the drop is classified, so it reports ++ `close` alone: the application decided, and no retry follows. ++ ++- **A message belongs to the session it arrived on, its answer and its receipt alike.** Maintainer's ++ call, 2026-09-01, narrowed 2026-09-29: a response carries the sequence number of the link it ++ arrived on, so answering on a new link succeeds and reports `{}` for a response that correlates ++ with nothing — goal 2's wrong answer. A receipt used to go out on the next link; now an `Sms` holds ++ its session's port and nothing else, since only a server session sends receipts and a server ++ session has no next link. Accepted: a hand-wired ESME that reports on a message across its own ++ reconnect gets `err` and opens the receipt on the new session itself. + + - **`reconnect` takes `{ minDelay, maxDelay }` to retune and `false` to turn off**, so absent means + on and there is one spelling for each. Only `client()` reconnects — a `server()` session is a +@@ -653,28 +676,42 @@ rule and an index of the titles below. + fall-through could be gated on it, which buys a fail-open path with state and an internal contract + no other collaborator needs. + +-- **The drain waits on the messages the application holds, and `sendResp()` is what says it is done +- with one.** Maintainer's call, 2026-09-01: waiting on the send window alone tore a server session +- down while the application was still answering a `submit_sm`, so the peer timed out and re-sent — +- the duplicate goal 2 forbids, in the direction the window already covers. No completion signal was +- added to the `sms` event: `sendResp()` is what an application already calls when it is done with a +- message, so it is the one the drain waits for. Counting every inbound request until `sendReturn()` +- answered it was rejected: an `onRequest` that deliberately answers nothing would then cost a full +- `shutdownTimeout` on every close. The response reaching the wire ends the wait, so a `sendResp()` +- the library refused or the socket would not carry leaves `close()` still reporting the message the +- peer is owed. +- +-- **The drain's wait on the application ignores `shutdownTimeout: 0`.** Waiting forever is safe for +- the peer, whose every request is bounded by `responseTimeout` unless the caller set that to 0 as +- well, and unsafe for the application, which nothing bounds — `close()` is what you reach for when +- the 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 +- 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 +- refused.** A bound the application cannot raise is the point: an application that answers nothing ++- **An inbound message is an `onSms` handler's, and the handler settling is what answers it.** ++ Maintainer's call, 2026-09-29, from the third comprehension round: the `sms` event capped every ++ reader — listener counts, `captureRejections` routed through a `WeakMap`, a `setImmediate` turn ++ and "answered" kept in three places. One handler is called directly and awaited, so a rejection ++ is a `catch`, and `sendResp()` in `sms.ts` is the one place a message is answered: it writes ++ once, and the handler's settle calls it for whatever it has not. Goal 2 settles the outcomes: a ++ bare return is the application saying it took the message, so `ESME_ROK`; a throw or rejection is ++ the application not taking it, so the retry status and the peer sends it again — never `ESME_ROK` ++ before the application has it, which is where round two's draft C failed. `sendResp()` stays for ++ the handler that must answer before it reports, since a receipt names an accepted message and ++ `sendDlr()` is refused until the answer. Rejected: the handler's return as the only answer, which ++ makes a receipt from inside the handler impossible or implicit. Rejected: implying the answer from ++ `sendDlr()`, which hides the wire order behind a method. Accepted: a returned `{ smsId }` after ++ `sendResp()` is a contradiction reported on `sessionError` and never written. ++ ++- **A session with no `onSms` refuses every message with the retry status.** Maintainer's call, ++ 2026-09-29, on the judgement call round two's draft D raised. Goal 4: an unanswered `deliver_sm` ++ has the peer time out and resend into silence, where `ESME_RX_T_APPN` (or `ESME_RTHROTTLED` on a ++ submission) tells it to keep the message; goal 2: nothing is dropped and nothing is claimed taken. ++ Rejected: answering `ESME_ROK` for nobody, which loses the message. ++ ++- **The drain waits on the `onSms` handlers still running, then on the requests on the wire, and a ++ handler running five minutes is no longer counted.** Maintainer's call, 2026-09-01, restated ++ 2026-09-29: waiting on the send window alone tore a server session down while the application was ++ still answering a `submit_sm`, so the peer timed out and re-sent — the duplicate goal 2 forbids. ++ The unit is the running handler, which is what the bound counts too, so one store answers both. ++ `shutdownTimeout: 0` waits forever for both halves, because the five-minute bound on a counted ++ handler is what keeps `close()` from blocking on an application that never returns; the old fall ++ back to `responseTimeout` for the message half is gone with the reason for it. `sendResp()` and ++ `sendDlr()` go out past the drain's refusal for as long as the socket lives, since answering and ++ reporting are what the drain waits for. ++ ++- **What the running handlers hold is capped on constants, and a message past the cap is ++ refused.** A bound the application cannot raise is the point: an application that never returns + would otherwise hold ever more messages, which goal 4 forbids. Reassembly's `maxOctets` is an +- option because it bounds what the peer sends; this bounds what the application leaves unanswered. ++ option because it bounds what the peer sends; this bounds what the application holds. + Maintainer's call, 2026-09-26. Refusing leaves the message with the peer, which will send it again + (goal 2). Rejected: dropping the oldest to make room, which frees nothing while the application + still holds its `Sms`, and stops the drain waiting for a message the peer is owed. Rejected: +@@ -693,23 +730,19 @@ rule and an index of the titles below. + `ESME_RTHROTTLED` is the SMSC's to send, so an ESME answers with SMPP 3.4's temporary receiver + 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()` +- 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 +- 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 +- `sessionError` like every other one given up on. +- +-- **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 +- `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 +- type to the same peer. ++- **The client owns the delivery-receipt merges; everything else the link held ends with its ++ session.** Each receipt is answered before the group it belongs to is complete, so a merge that ++ died with a session would lose receipts no peer has a reason to send again; `DlrMerger` lives in ++ `SmppClient` and is fed from each session's `dlr`. Inbound segments end with the session: 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 `sessionError` like every other one given up on. ++ ++- **The bind state is the session's, and `bound()` alone writes it.** 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. In a reconnect's gap there is no session, so ++ `SmppClient.sendSms()` refuses a receiver bind from its own `bindType` option. + + - **A message id base is merged at most once.** A receipt carries nothing but `-`, so a + straggler for a message whose group is gone cannot be told from a receipt for a later message the +@@ -750,14 +783,13 @@ rule and an index of the titles below. + an abort while held for a link already gives. The drain half needs nothing: `close({ signal })` already hands the signal to + `window.idle()`, and `unbind()` taking none is the shape README states. + +-- **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. +- `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. ++- **The reconnect loop alone decides whether a link can carry a request, and a bound session is ++ one.** Maintainer's call, 2026-09-01, restated 2026-09-29; goal 1, since a send on a link not yet ++ bound comes back `ESME_RINVBNDSTS`. `ReconnectLoop.adopt()` takes a session only once ++ `openBoundSession()` bound it, so `bound()` resolves with nothing that cannot carry a request, and ++ the session itself keeps two booleans — `stopping` for a drain begun and `over` for a socket gone ++ — and no phase. Rejected: gating on the socket being open, which admits a send one round trip ++ before the bind is answered. + + + ## Internals and tests +@@ -777,7 +809,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 `ReconnectLoop`, `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 +diff --git a/interop-tests/cloudhopper.test.ts b/interop-tests/cloudhopper.test.ts +index dca5b5b..fc93a23 100644 +--- a/interop-tests/cloudhopper.test.ts ++++ b/interop-tests/cloudhopper.test.ts +@@ -1,6 +1,7 @@ + import assert from 'node:assert/strict'; + import { readFileSync } from 'node:fs'; + import test, { after, describe } from 'node:test'; ++import type { OnSms } from '../src/session-options.ts'; + import type { Session } from '../src/session.ts'; + import type { Sms } from '../src/sms.ts'; + import type { SmppServer } from '../src/server.ts'; +@@ -38,26 +39,43 @@ async function driver(path: string, params: Record = {}): Promis + return response.json() as Promise; + } + +-const manualTexts = new Set(); ++/** Messages a test answers itself: the handler holds each until released, since returning answers it. */ ++const manualTexts = new Map>(); + const allSms: { session: Session; sms: Sms }[] = []; + +-function attach(session: Session): void { +- session.on('sms', sms => { +- allSms.push({ session, sms }); ++function answerManually(text: string): () => void { ++ let release = (): void => undefined; + +- if (manualTexts.has(sms.message)) return; ++ manualTexts.set(text, new Promise(resolve => { release = resolve; })); + +- // The slow server this phase's window scenarios need: every ordinary submit is held for +- // SLOW_DELAY_MS before being answered, so a burst genuinely presses on a small window. +- void delay(SLOW_DELAY_MS).then(() => sms.sendResp()); +- }); ++ return () => { release(); }; + } + +-const { err: serverErr, server: smpp } = await server({ authenticate: () => true, idleTimeout: 40_000, port: SMPP_PORT }); ++const handleSms: OnSms = async sms => { ++ allSms.push({ session: sms.session, sms }); ++ ++ const held = manualTexts.get(sms.message); ++ ++ if (held) { ++ await held; ++ ++ return; ++ } ++ ++ // The slow server this phase's window scenarios need: every ordinary submit is held for ++ // SLOW_DELAY_MS before being answered, so a burst genuinely presses on a small window. ++ await delay(SLOW_DELAY_MS); ++}; ++ ++const { err: serverErr, server: smpp } = await server({ ++ authenticate: () => true, ++ idleTimeout: 40_000, ++ onSms: handleSms, ++ port: SMPP_PORT, ++}); + + assert.equal(serverErr, undefined); + assert.ok(smpp); +-smpp.on('session', attach); + + const key = readFileSync('/shared-certs/server.key'); + const cert = readFileSync('/shared-certs/server.crt'); +@@ -67,13 +85,13 @@ const cert = readFileSync('/shared-certs/server.crt'); + const { err: tlsServerErr, server: tlsSmpp } = await server({ + authenticate: () => true, + idleTimeout: 40_000, ++ onSms: handleSms, + port: TLS_PORT, + tls: { cert, key, maxVersion: 'TLSv1.2' }, + }); + + assert.equal(tlsServerErr, undefined); + assert.ok(tlsSmpp); +-tlsSmpp.on('session', attach); + + after(async () => { + await smpp.close(); +@@ -138,8 +156,7 @@ describe('S5 - request expiry shorter than the handler delay (target 11)', () => + await waitForSessionCount(smpp, 1); + + const text = 'expiry-probe'; +- +- manualTexts.add(text); ++ const release = answerManually(text); + + const submitted = driver('/submit', { session: 'expiry', text, timeoutMs: '5000' }); + const sms = await waitFor(() => allSms.find(entry => entry.sms.message === text)?.sms); +@@ -150,6 +167,7 @@ describe('S5 - request expiry shorter than the handler delay (target 11)', () => + // monitor gives up on it first - recorded, not asserted against, since that is the peer's call. + await delay(1000); + await sms.sendResp(); ++ release(); + + const result = await submitted; + +@@ -196,14 +214,14 @@ describe('a refusing status is surfaced back to Cloudhopper', () => { + await waitForSessionCount(smpp, 1); + + const text = 'ch-refuse-me'; +- +- manualTexts.add(text); ++ const release = answerManually(text); + + const submitted = driver('/submit', { session: 'refuse', text, timeoutMs: '5000' }); + const sms = await waitFor(() => allSms.find(entry => entry.sms.message === text)?.sms); + + assert.ok(sms); + await sms.sendResp({ status: 'ESME_RMSGQFUL' }); ++ release(); + + const result = await submitted; + +diff --git a/interop-tests/dumbclient.test.ts b/interop-tests/dumbclient.test.ts +index 81bc9ff..59c1ac1 100644 +--- a/interop-tests/dumbclient.test.ts ++++ b/interop-tests/dumbclient.test.ts +@@ -101,20 +101,6 @@ const memTimer = setInterval(() => { + + memTimer.unref(); + +-const { err, server: smpp } = await server({ +- authenticate: ({ systemId }) => ({ userData: { systemId } satisfies ScenarioUserData }), +- idleTimeout: 40_000, +- log, +- port: SMPP_PORT, +-}); +- +-assert.equal(err, undefined); +-assert.ok(smpp); +- +-const serverErrors: Error[] = []; +- +-smpp.on('serverError', serverError => { serverErrors.push(serverError); }); +- + const sessionByScenario = new Map(); + const slowQueues = new Map>(); + +@@ -126,23 +112,27 @@ function answered(session: Session, arrivalIndex: number, result: { err?: Error + if (result.err) s.unansweredErrors++; + } + +-function slowRespond(session: Session, sms: Sms, arrivalIndex: number): void { ++function slowRespond(session: Session, sms: Sms, arrivalIndex: number): Promise { + const chain = (slowQueues.get(session) ?? Promise.resolve()) + .then(async () => { await delay(SLOW_HANDLER_DELAY_MS); }) + .then(async () => { answered(session, arrivalIndex, await sms.sendResp()); }); + + slowQueues.set(session, chain); +-} + +-function fastRespond(session: Session, sms: Sms, arrivalIndex: number): void { +- void sms.sendResp().then(result => { answered(session, arrivalIndex, result); }); ++ return chain; + } + +-smpp.on('session', session => { +- // Attached now, not lazily in a test body - see the comment on ScenarioStats.closed. +- session.on('close', () => { statsFor(scenarioOf(session)).closed = true; }); ++async function fastRespond(session: Session, sms: Sms, arrivalIndex: number): Promise { ++ answered(session, arrivalIndex, await sms.sendResp()); ++} + +- session.on('sms', sms => { ++const { err, server: smpp } = await server({ ++ authenticate: ({ systemId }) => ({ userData: { systemId } satisfies ScenarioUserData }), ++ idleTimeout: 40_000, ++ log, ++ // Answers through sendResp() so the result feeds the stats; the handler runs until the answer is out. ++ onSms: sms => { ++ const { session } = sms; + const name = scenarioOf(session); + + sessionByScenario.set(name, session); +@@ -155,9 +145,23 @@ smpp.on('session', session => { + else s.ids.add(sms.smsId); + s.peakOutstanding = Math.max(s.peakOutstanding, s.arrived - s.answered); + +- if (name === 'dumb-w500' || name === 'dumb-w2000') slowRespond(session, sms, arrivalIndex); +- else fastRespond(session, sms, arrivalIndex); +- }); ++ return name === 'dumb-w500' || name === 'dumb-w2000' ++ ? slowRespond(session, sms, arrivalIndex) ++ : fastRespond(session, sms, arrivalIndex); ++ }, ++ port: SMPP_PORT, ++}); ++ ++assert.equal(err, undefined); ++assert.ok(smpp); ++ ++const serverErrors: Error[] = []; ++ ++smpp.on('serverError', serverError => { serverErrors.push(serverError); }); ++ ++smpp.on('session', session => { ++ // Attached now, not lazily in a test body - see the comment on ScenarioStats.closed. ++ session.on('close', () => { statsFor(scenarioOf(session)).closed = true; }); + }); + + function memShape(): string { +@@ -206,10 +210,10 @@ after(async () => { + + // S9 (target 11) and the backpressure-at-server scenario: window 2000 at a high rate against a + // handler slowed enough to build a real backlog. window500 is the same shape with a window below +-// maxHeldMessages (1000, session-options.ts defaults.maxHeldMessages), the bound past which a +-// peer's window is answered ESME_RTHROTTLED. smpp-dumb-client counts a throttled message as sent +-// and never resends it, so window 2000 accounts for 20,000 as answered plus throttled. +-const throttleMessage = 'session - unanswered messages at their bound, asking the peer to retry'; ++// maxRunningHandlers (1000, src/defaults.ts), the bound past which a peer's window is answered ++// ESME_RTHROTTLED. smpp-dumb-client counts a throttled message as sent and never resends it, so ++// window 2000 accounts for 20,000 as answered plus throttled. ++const throttleMessage = 'session - handlers at their bound, asking the peer to retry'; + + // window500's peak (<=500) and the soak's never reach the 1000 default, so every refusal is + // necessarily from the w2000 session - the runs share one server and one log. +@@ -236,7 +240,7 @@ describe('S9 - bounded window against a slowed handler', () => { + }); + } + +- test('window 2000 pressed past maxHeldMessages (1000): the peer is throttled, window500 never is', () => { ++ test('window 2000 pressed past maxRunningHandlers (1000): the peer is throttled, window500 never is', () => { + assert.ok(throttled('dumb-w2000') > 0, 'expected at least one ESME_RTHROTTLED under window 2000'); + assert.ok(statsFor('dumb-w2000').peakOutstanding <= 1000); + assert.equal(statsFor('dumb-w500').peakOutstanding <= 500, true); +@@ -278,8 +282,8 @@ describe('S6 - idle peer, no enquire_link at all', () => { + + assert.ok(droppedIdle, 'server never dropped the idle peer within idleTimeout + slack'); + assert.ok(logEntries.some(entry => entry.message === 'linkTimers - closing an idle peer')); +- // teardown() (session.ts) is a raw close, not an unbind exchange - nothing further is on the +- // wire for this session, which the capture's histogram (findings/07-load.md) confirms. ++ // The idle drop (link-timers.ts) destroys the socket, no unbind exchange - nothing further is ++ // on the wire for this session, which the capture's histogram (findings/07-load.md) confirms. + assert.equal(statsFor('dumb-idle').answered, 1); + }); + }); +diff --git a/interop-tests/jasmin.test.ts b/interop-tests/jasmin.test.ts +index c6c9b92..71debb1 100644 +--- a/interop-tests/jasmin.test.ts ++++ b/interop-tests/jasmin.test.ts +@@ -70,6 +70,15 @@ function variantFromSystemId(systemId: string): UpstreamVariant | undefined { + return undefined; + } + ++/** The variant authenticate() recorded on the session, once it has run. */ ++function variantOf(session: Session): UpstreamVariant | undefined { ++ const { userData } = session; ++ ++ if (typeof userData !== 'object' || userData === null || !('variant' in userData)) return undefined; ++ ++ return userData.variant === 'main' || userData.variant === 'datasm' ? userData.variant : undefined; ++} ++ + const { err: upstreamErr, server: upstream } = await server({ + authenticate: ({ password, systemId }) => { + // <=8 chars: Jasmin's own bind-PDU encoder enforces SMPP's 8-char password maximum strictly +@@ -86,6 +95,18 @@ const { err: upstreamErr, server: upstream } = await server({ + // was already in flight for Jasmin's own requeue_delay (120s default) before it retries - far past + // any per-test wait budget here - so this is generous specifically to never be the trigger. + idleTimeout: 300_000, ++ onSms: async sms => { ++ const variant = variantOf(sms.session); ++ ++ if (variant) upstreamSms.push({ sms, variant }); ++ ++ await sms.sendResp(); ++ ++ if (sms.dlr) { ++ await delay(150); ++ await sms.sendDlr('DELIVERED'); ++ } ++ }, + port: UPSTREAM_PORT, + }); + +@@ -97,7 +118,7 @@ const upstreamServer = upstream; + upstreamServer.on('session', session => { + // `session` fires on raw connect, before authenticate() has run - session.userData is not set + // yet, so the map is populated off the bind PDU itself (like kannel.test.ts's bindPdus), not off +- // userData; userData is only read later, from 'sms', where authenticate() has long since run. ++ // userData; userData is only read later, from onSms, where authenticate() has long since run. + session.on('incomingPduObj', pduObj => { + if (!pduObj.cmdName.startsWith('bind_')) return; + +@@ -105,21 +126,6 @@ upstreamServer.on('session', session => { + + if (variant) upstreamSessions.set(variant, session); + }); +- +- session.on('sms', sms => { +- const variant = (session.userData as { variant?: UpstreamVariant } | undefined)?.variant; +- +- if (variant) upstreamSms.push({ sms, variant }); +- +- void (async () => { +- await sms.sendResp(); +- +- if (sms.dlr) { +- await delay(150); +- await sms.sendDlr('DELIVERED'); +- } +- })(); +- }); + }); + + async function waitForUpstreamSession(variant: UpstreamVariant, budget = 20_000): Promise { +@@ -258,31 +264,32 @@ describe('C1 - bind, enquire_link, unbind', () => { + // own default 20s keepalive counts as activity and resets it, so Jasmin's probe never has a + // chance to fire on its own. Disabling ours (and widening idleTimeout, which defaults off + // enquireLinkInterval and would otherwise become 0) leaves the link quiet long enough to see it. +- const { err, session } = await bind(USERNAME, PASSWORD, { enquireLinkInterval: 0, idleTimeout: 60_000 }); ++ const { err, client: esme } = await bind(USERNAME, PASSWORD, { enquireLinkInterval: 0, idleTimeout: 60_000 }); + + assert.equal(err, undefined); +- assert.ok(session); ++ assert.ok(esme); ++ assert.ok(esme.session); + + const incoming: PduObject[] = []; + const closes: true[] = []; + const sessionErrors: Error[] = []; + +- session.on('incomingPduObj', pduObj => { incoming.push(pduObj); }); +- session.on('close', () => { closes.push(true); }); +- session.on('sessionError', sessionError => { sessionErrors.push(sessionError); }); ++ esme.session.on('incomingPduObj', pduObj => { incoming.push(pduObj); }); ++ esme.on('close', () => { closes.push(true); }); ++ esme.on('sessionError', sessionError => { sessionErrors.push(sessionError); }); + + // enquireLinkTimerSecs is 30 in Jasmin's default [smpp-server] config. + const theirs = await waitFor(() => incoming.find(pduObj => pduObj.cmdName === 'enquire_link'), 35_000); + + assert.ok(theirs, 'expected Jasmin to send its own enquire_link within 35s'); + +- const ours = await session.send({ cmdName: 'enquire_link' }); ++ const ours = await esme.send({ cmdName: 'enquire_link' }); + + assert.equal(ours.err, undefined); + assert.ok(ours.pduObj); + assert.equal(ours.pduObj.cmdStatus, 'ESME_ROK'); + +- const unbound = await session.unbind(); ++ const unbound = await esme.unbind(); + + assert.equal(unbound.err, undefined); + assert.ok(await waitFor(() => (closes.length > 0 ? true : undefined), 5000), 'expected a clean close after unbind'); +@@ -290,21 +297,23 @@ describe('C1 - bind, enquire_link, unbind', () => { + }); + + test('binds transmitter', async t => { +- const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'transmitter' }); ++ const { err, client: esme } = await bind(USERNAME, PASSWORD, { bindType: 'transmitter' }); + + assert.equal(err, undefined); +- assert.ok(session); +- closeAfter(t, session); +- assert.equal(session.boundAs, 'transmitter'); ++ assert.ok(esme); ++ closeAfter(t, esme); ++ assert.ok(esme.session); ++ assert.equal(esme.session.boundAs, 'transmitter'); + }); + + test('binds receiver', async t => { +- const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' }); ++ const { err, client: esme } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' }); + + assert.equal(err, undefined); +- assert.ok(session); +- closeAfter(t, session); +- assert.equal(session.boundAs, 'receiver'); ++ assert.ok(esme); ++ closeAfter(t, esme); ++ assert.ok(esme.session); ++ assert.equal(esme.session.boundAs, 'receiver'); + }); + }); + +@@ -312,16 +321,16 @@ describe('C13 - maxOutstanding 1 with 10 parallel sends', () => { + test('every send is answered, none lost, order preserved at the fake upstream', async t => { + await waitForUpstreamSession('main'); + +- const { err, session } = await bind(USERNAME, PASSWORD, { maxOutstanding: 1 }); ++ const { err, client: esme } = await bind(USERNAME, PASSWORD, { maxOutstanding: 1 }); + + assert.equal(err, undefined); +- assert.ok(session); +- closeAfter(t, session); ++ assert.ok(esme); ++ closeAfter(t, esme); + + const before = upstreamSms.length; + const texts = Array.from({ length: 10 }, (_, i) => `c13-order-${String(i).padStart(2, '0')}`); + +- const results = await Promise.all(texts.map(async message => session.sendSms({ from: FROM, message, to: TO }))); ++ const results = await Promise.all(texts.map(async message => esme.sendSms({ from: FROM, message, to: TO }))); + + const ids: string[] = []; + +@@ -380,7 +389,7 @@ describe('S7 - Jasmin as the ESME against our server (HTTP send API, DLR callbac + assert.ok(arrived, 'expected the HTTP-submitted message to arrive as submit_sm at our server()'); + + // Our server() already answered (sendResp) and sent the DLR (sendDlr('DELIVERED')) from the +- // shared session handler above - Jasmin's own DLR pipeline should throw the HTTP callback. ++ // shared onSms above - Jasmin's own DLR pipeline should throw the HTTP callback. + const msgidMatch = /Success "([^"]+)"/i.exec(sent.body); + const msgid = msgidMatch?.[1]; + +@@ -397,14 +406,11 @@ describe('S7 - Jasmin as the ESME against our server (HTTP send API, DLR callbac + + test('a long GSM message from our server reassembles at Jasmin (or is recorded as fragments)', async () => { + const upstreamSession = await waitForUpstreamSession('main'); +- const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' }); +- +- assert.equal(err, undefined); +- assert.ok(session); +- + const sms: Sms[] = []; ++ const { err, client: esme } = await bind(USERNAME, PASSWORD, { bindType: 'receiver', onSms: s => { sms.push(s); } }); + +- session.on('sms', s => { sms.push(s); }); ++ assert.equal(err, undefined); ++ assert.ok(esme); + + const text = `s7-long-${'p'.repeat(300)}`; + +@@ -412,20 +418,17 @@ describe('S7 - Jasmin as the ESME against our server (HTTP send API, DLR callbac + + const whole = await waitFor(() => sms.find(s => s.message === text), 10_000); + +- await session.close({ signal: AbortSignal.abort() }); ++ await esme.close({ signal: AbortSignal.abort() }); + assert.ok(whole ?? sms.length > 0, 'expected the long message to arrive whole or as recorded fragments'); + }); + + test('a UCS-2 message with 一 and an emoji from our server (or is recorded as fragments)', async () => { + const upstreamSession = await waitForUpstreamSession('main'); +- const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' }); +- +- assert.equal(err, undefined); +- assert.ok(session); +- + const sms: Sms[] = []; ++ const { err, client: esme } = await bind(USERNAME, PASSWORD, { bindType: 'receiver', onSms: s => { sms.push(s); } }); + +- session.on('sms', s => { sms.push(s); }); ++ assert.equal(err, undefined); ++ assert.ok(esme); + + const text = `一😀${'q'.repeat(60)}`; + +@@ -433,7 +436,7 @@ describe('S7 - Jasmin as the ESME against our server (HTTP send API, DLR callbac + + const whole = await waitFor(() => sms.find(s => s.message === text), 10_000); + +- await session.close({ signal: AbortSignal.abort() }); ++ await esme.close({ signal: AbortSignal.abort() }); + assert.ok(whole ?? sms.length > 0, 'expected the UCS-2 message to arrive whole or as recorded fragments'); + }); + }); +@@ -452,19 +455,19 @@ describe('C3+C7 - long MT through the fake upstream, receipts and id consistency + test(testCase.label, async t => { + await waitForUpstreamSession('main'); + +- const { err, session } = await bind(USERNAME, PASSWORD); ++ const { err, client: esme } = await bind(USERNAME, PASSWORD); + + assert.equal(err, undefined); +- assert.ok(session); +- closeAfter(t, session); ++ assert.ok(esme); ++ closeAfter(t, esme); + + const dlrs: { dlr: Dlr; pduObj: PduObject }[] = []; + const messageDlrs: unknown[] = []; + +- session.on('dlr', (dlr, pduObj) => { dlrs.push({ dlr, pduObj }); }); +- session.on('messageDlr', merged => { messageDlrs.push(merged); }); ++ esme.on('dlr', (dlr, pduObj) => { dlrs.push({ dlr, pduObj }); }); ++ esme.on('messageDlr', merged => { messageDlrs.push(merged); }); + +- const sent = await session.sendSms({ ++ const sent = await esme.sendSms({ + dlr: true, + from: FROM, + message: testCase.message, +@@ -502,14 +505,11 @@ describe('C3+C7 - long MT through the fake upstream, receipts and id consistency + describe('C8 (target 3) - long MO from an upstream SMSC, SAR vs UDH segmentation', () => { + test('SAR-segmented deliver_sm from the fake upstream', async () => { + const upstreamSession = await waitForUpstreamSession('main'); +- const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' }); +- +- assert.equal(err, undefined); +- assert.ok(session); +- + const sms: Sms[] = []; ++ const { err, client: esme } = await bind(USERNAME, PASSWORD, { bindType: 'receiver', onSms: s => { sms.push(s); } }); + +- session.on('sms', s => { sms.push(s); }); ++ assert.equal(err, undefined); ++ assert.ok(esme); + + const text = `sar-mo-${'m'.repeat(300)}`; + +@@ -518,7 +518,7 @@ describe('C8 (target 3) - long MO from an upstream SMSC, SAR vs UDH segmentation + const whole = await waitFor(() => sms.find(s => s.message === text), 10_000); + const fragments = sms.filter(s => s.message !== text && text.includes(s.message) && s.message !== ''); + +- await session.close({ signal: AbortSignal.abort() }); ++ await esme.close({ signal: AbortSignal.abort() }); + + assert.ok(whole, 'expected the SAR segments to reassemble into one whole sms'); + assert.deepEqual(fragments.map(s => s.message), [], 'no segment reaches the application on its own'); +@@ -526,14 +526,11 @@ describe('C8 (target 3) - long MO from an upstream SMSC, SAR vs UDH segmentation + + test('UDH-segmented deliver_sm from the fake upstream', async () => { + const upstreamSession = await waitForUpstreamSession('main'); +- const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' }); +- +- assert.equal(err, undefined); +- assert.ok(session); +- + const sms: Sms[] = []; ++ const { err, client: esme } = await bind(USERNAME, PASSWORD, { bindType: 'receiver', onSms: s => { sms.push(s); } }); + +- session.on('sms', s => { sms.push(s); }); ++ assert.equal(err, undefined); ++ assert.ok(esme); + + const text = `udh-mo-${'n'.repeat(300)}`; + +@@ -542,7 +539,7 @@ describe('C8 (target 3) - long MO from an upstream SMSC, SAR vs UDH segmentation + const whole = await waitFor(() => sms.find(s => s.message === text), 10_000); + const fragments = sms.filter(s => text.includes(s.message) && s.message !== ''); + +- await session.close({ signal: AbortSignal.abort() }); ++ await esme.close({ signal: AbortSignal.abort() }); + + assert.ok(whole ?? fragments.length > 0, 'expected either a reassembled sms or UDH fragments to arrive'); + }); +@@ -551,14 +548,11 @@ describe('C8 (target 3) - long MO from an upstream SMSC, SAR vs UDH segmentation + describe('C8 (target 2) - message_payload with sm_length 0', () => { + test('a deliver_sm carrying message_payload instead of short_message', async () => { + const upstreamSession = await waitForUpstreamSession('main'); +- const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' }); +- +- assert.equal(err, undefined); +- assert.ok(session); +- + const sms: Sms[] = []; ++ const { err, client: esme } = await bind(USERNAME, PASSWORD, { bindType: 'receiver', onSms: s => { sms.push(s); } }); + +- session.on('sms', s => { sms.push(s); }); ++ assert.equal(err, undefined); ++ assert.ok(esme); + + const text = 'message-payload only, sm_length 0'; + const pushed = await sendMessagePayloadMo(upstreamSession, { from: TO, message: text, to: FROM }); +@@ -567,7 +561,7 @@ describe('C8 (target 2) - message_payload with sm_length 0', () => { + + const arrived = await waitFor(() => sms.find(s => s.message === text), 5000); + +- await session.close({ signal: AbortSignal.abort() }); ++ await esme.close({ signal: AbortSignal.abort() }); + + // Jasmin relays message_payload faithfully (sm_length 0, the real text in the TLV), so the + // whole body has to reach the application from there. +@@ -581,19 +575,20 @@ describe('C9 (target 4) - DLR as data_sm against the jasmin-datasm instance', () + test('a receipt thrown as data_sm reaches the dlr event', async t => { + await waitForUpstreamSession('datasm'); + +- const { err, session } = await client({ host: DATASM_HOST, password: PASSWORD, port: PEER_PORT, username: USERNAME }); ++ const { err, client: esme } = await client({ host: DATASM_HOST, password: PASSWORD, port: PEER_PORT, username: USERNAME }); + + assert.equal(err, undefined); +- assert.ok(session); +- closeAfter(t, session); ++ assert.ok(esme); ++ closeAfter(t, esme); ++ assert.ok(esme.session); + + const dlrs: Dlr[] = []; + const incomingDataSm: PduObject[] = []; + +- session.on('dlr', dlr => { dlrs.push(dlr); }); +- session.on('incomingPduObj', pduObj => { if (pduObj.cmdName === 'data_sm') incomingDataSm.push(pduObj); }); ++ esme.on('dlr', dlr => { dlrs.push(dlr); }); ++ esme.session.on('incomingPduObj', pduObj => { if (pduObj.cmdName === 'data_sm') incomingDataSm.push(pduObj); }); + +- const sent = await session.sendSms({ dlr: true, from: FROM, message: 'data_sm dlr test', to: TO }); ++ const sent = await esme.sendSms({ dlr: true, from: FROM, message: 'data_sm dlr test', to: TO }); + + assert.equal(sent.err, undefined); + +@@ -623,10 +618,10 @@ describe('C11 - bind refusal and reconnect backoff', () => { + warn: () => undefined, + }; + +- const { err, session } = await bind(USERNAME, 'wrong-password', { log, reconnect: { maxDelay: 4000, minDelay: 1000 } }); ++ const { err, client: esme } = await bind(USERNAME, 'wrong-password', { log, reconnect: { maxDelay: 4000, minDelay: 1000 } }); + + assert.ok(err); +- assert.equal(session, undefined); ++ assert.equal(esme, undefined); + await delay(2000); + assert.equal(refusals.length, 1, 'expected exactly one bind attempt, never a retry'); + assert.equal(refusals[0]?.cmdStatus, 'ESME_RINVPASWD'); +@@ -640,17 +635,18 @@ describe('C11 - bind refusal and reconnect backoff', () => { + reconnect: { maxDelay: 4000, minDelay: 1000 }, + username: USERNAME, + }; +- const { err, session } = await client(options); ++ const { err, client: esme } = await client(options); + + assert.equal(err, undefined); +- assert.ok(session); +- closeAfter(t, session); ++ assert.ok(esme); ++ closeAfter(t, esme); ++ assert.ok(esme.session); + + const disconnectedAt: number[] = []; + +- session.on('disconnected', () => { disconnectedAt.push(Date.now()); }); ++ esme.on('disconnected', () => { disconnectedAt.push(Date.now()); }); + options.password = 'wrong-after-drop'; +- session.sock.destroy(); ++ esme.session.sock.destroy(); + + await delay(12_000); + +@@ -670,14 +666,14 @@ describe('C12 - throttling (esme2\'s smpps_throughput quota)', () => { + test('flooding submits past the quota gets an err naming the status; the session stays bound; a later send works', async t => { + await waitForUpstreamSession('main'); + +- const { err, session } = await bind(THROTTLED_USERNAME, THROTTLED_PASSWORD, { maxOutstanding: 20 }); ++ const { err, client: esme } = await bind(THROTTLED_USERNAME, THROTTLED_PASSWORD, { maxOutstanding: 20 }); + + assert.equal(err, undefined); +- assert.ok(session); +- closeAfter(t, session); ++ assert.ok(esme); ++ closeAfter(t, esme); + + const results = await Promise.all( +- Array.from({ length: 15 }, async (_unused, index) => session.sendSms({ from: FROM, message: `throttle-${String(index)}`, to: TO })), ++ Array.from({ length: 15 }, async (_unused, index) => esme.sendSms({ from: FROM, message: `throttle-${String(index)}`, to: TO })), + ); + + const refused = results.filter(r => r.err !== undefined); +@@ -685,7 +681,7 @@ describe('C12 - throttling (esme2\'s smpps_throughput quota)', () => { + assert.ok(refused.length > 0, 'expected the 0.1/s quota to refuse at least one of 15 parallel sends'); + assert.match(refused[0]?.err?.message ?? '', /ESME_RTHROTTLED/); + +- const keepalive = await session.send({ cmdName: 'enquire_link' }); ++ const keepalive = await esme.send({ cmdName: 'enquire_link' }); + + assert.equal(keepalive.err, undefined); + assert.ok(keepalive.pduObj); +@@ -694,10 +690,10 @@ describe('C12 - throttling (esme2\'s smpps_throughput quota)', () => { + // 0.1/s is one slot every 10s, so a single fixed delay is either wasteful or flaky - polling + // finds the next open slot instead of guessing it. + const deadline = Date.now() + 25_000; +- let later: Awaited> | undefined; ++ let later: Awaited> | undefined; + + while (!later && Date.now() < deadline) { +- const attempt = await session.sendSms({ from: FROM, message: 'after the burst', to: TO }); ++ const attempt = await esme.sendSms({ from: FROM, message: 'after the burst', to: TO }); + + if (!attempt.err) later = attempt; + else await delay(500); +diff --git a/interop-tests/jsmpp.test.ts b/interop-tests/jsmpp.test.ts +index db96513..4889b87 100644 +--- a/interop-tests/jsmpp.test.ts ++++ b/interop-tests/jsmpp.test.ts +@@ -39,13 +39,25 @@ async function driver(path: string, params: Record = {}): Promis + const allSms: { session: Session; sms: Sms }[] = []; + const allSessionErrors: { err: Error; session: Session }[] = []; + const bindPdus: Record[] = []; +-/** Messages a test answers itself (a refusing status, or asserting on the response) - populate +- * before triggering the submit that will carry this exact text, so the global auto-ack never runs. */ +-const manualTexts = new Set(); ++/** Messages a test answers itself (a refusing status, or asserting on the response): the handler ++ * holds each until released, since returning answers it - register before triggering the submit. */ ++const manualTexts = new Map>(); ++ ++function answerManually(text: string): () => void { ++ let release = (): void => undefined; ++ ++ manualTexts.set(text, new Promise(resolve => { release = resolve; })); ++ ++ return () => { release(); }; ++} + + const { err: serverErr, server: smpp } = await server({ + authenticate: () => true, + idleTimeout: 40_000, ++ onSms: async sms => { ++ allSms.push({ session: sms.session, sms }); ++ await manualTexts.get(sms.message); ++ }, + port: SMPP_PORT, + }); + +@@ -58,10 +70,6 @@ smppServer.on('session', session => { + session.on('incomingPduObj', pduObj => { + if (pduObj.cmdName.startsWith('bind_')) bindPdus.push(pduObj.params); + }); +- session.on('sms', sms => { +- allSms.push({ session, sms }); +- if (!manualTexts.has(sms.message)) void sms.sendResp(); +- }); + session.on('sessionError', err => { allSessionErrors.push({ err, session }); }); + }); + +@@ -263,7 +271,7 @@ describe('S3 - known-but-unhandled and malformed commands (targets 1, 6)', () => + + // Not reachable through jsmpp's own typed API at all (it cannot construct wire garbage), so this + // is a raw fixture opened directly against our server(). +- test('a deliver_sm ending in a bare TLV header gets ESME_RINVTLVSTREAM, and reaches no listener', async t => { ++ test('a deliver_sm ending in a bare TLV header gets ESME_RINVTLVSTREAM, and reaches no handler', async t => { + await waitForSessions(1); + + const sock = net.connect(SMPP_PORT, '127.0.0.1'); +@@ -311,13 +319,13 @@ describe('a refusing status is surfaced back to jsmpp', () => { + await waitForSessions(1); + + const text = 'refuse-me'; +- +- manualTexts.add(text); ++ const release = answerManually(text); + + const submitted = driver('/submit', { encoding: 'gsm7', from: '1001', mode: 'plain', session: 'v34', text, to: '2001' }); + const sms = await waitForSms(text); + + await sms.sendResp({ status: 'ESME_RMSGQFUL' }); ++ release(); + + const result = await submitted; + +diff --git a/interop-tests/kannel.test.ts b/interop-tests/kannel.test.ts +index 0d911d0..c9c36d7 100644 +--- a/interop-tests/kannel.test.ts ++++ b/interop-tests/kannel.test.ts +@@ -141,9 +141,30 @@ function variantFromSystemId(systemId: string): Variant | undefined { + return undefined; + } + ++const variants: readonly Variant[] = ['iv33', 'main', 'maxp1', 'notrx']; ++ ++/** The variant authenticate() recorded on the session, once it has run. */ ++function variantOf(session: Session): Variant | undefined { ++ const { userData } = session; ++ ++ if (typeof userData !== 'object' || userData === null || !('variant' in userData)) return undefined; ++ ++ return variants.find(variant => variant === userData.variant); ++} ++ + const allSms: { sms: Sms; variant: Variant }[] = []; + const allDlrs: { dlr: Dlr; variant: Variant }[] = []; + const bindPdus: { params: Record; variant: Variant }[] = []; ++/** Messages a test answers itself: the handler holds each until released, since returning answers it. */ ++const manualTexts = new Map>(); ++ ++function answerManually(text: string): () => void { ++ let release = (): void => undefined; ++ ++ manualTexts.set(text, new Promise(resolve => { release = resolve; })); ++ ++ return () => { release(); }; ++} + + const { err: serverErr, server: smpp } = await server({ + authenticate: ({ password, systemId }) => { +@@ -154,6 +175,13 @@ const { err: serverErr, server: smpp } = await server({ + return variant ? { userData: { variant } } : false; + }, + idleTimeout: 40_000, ++ onSms: async sms => { ++ const variant = variantOf(sms.session); ++ ++ if (variant) allSms.push({ sms, variant }); ++ ++ await manualTexts.get(sms.message); ++ }, + port: SMPP_PORT, + }); + +@@ -171,14 +199,8 @@ smppServer.on('session', session => { + if (variant) bindPdus.push({ params: pduObj.params, variant }); + }); + +- session.on('sms', sms => { +- const variant = (session.userData as { variant?: Variant } | undefined)?.variant; +- +- if (variant) allSms.push({ sms, variant }); +- }); +- + session.on('dlr', dlr => { +- const variant = (session.userData as { variant?: Variant } | undefined)?.variant; ++ const variant = variantOf(session); + + if (variant) allDlrs.push({ dlr, variant }); + }); +@@ -190,7 +212,7 @@ after(async () => { + }); + + function sessionsFor(variant: Variant): Session[] { +- return [...smppServer.sessions].filter(s => (s.userData as { variant?: Variant } | undefined)?.variant === variant); ++ return [...smppServer.sessions].filter(s => variantOf(s) === variant); + } + + async function waitForSessions(variant: Variant, count: number, budget = 15_000): Promise { +@@ -308,8 +330,6 @@ describe('S1 - MT from Kannel with delivery reports', () => { + assert.equal(sms.to, '46709771337'); + assert.equal(sms.dlr, true); + +- assert.equal((await sms.sendResp()).err, undefined); +- + // dlr-mask bit 8: Kannel fires this off the submit_sm_resp alone, before any receipt. + const submitAck = await waitForDlrCallback(sms.smsId, '8'); + +@@ -339,7 +359,6 @@ describe('long MT from Kannel', () => { + const sms = await waitForSms('main', text, 15_000); + + assert.equal(sms.message, text); +- assert.equal((await sms.sendResp()).err, undefined); + }); + + test('UCS-2 text with 一 and an emoji reassembles whole', async () => { +@@ -351,7 +370,6 @@ describe('long MT from Kannel', () => { + const sms = await waitForSms('main', text, 15_000); + + assert.equal(sms.message, text); +- assert.equal((await sms.sendResp()).err, undefined); + }); + }); + +@@ -365,7 +383,6 @@ describe('S11 - GSM extension characters', () => { + const sms = await waitForSms('main', text); + + assert.equal(sms.message, text); +- assert.equal((await sms.sendResp()).err, undefined); + }); + }); + +@@ -462,13 +479,15 @@ describe('S6 - wait-ack expiry and keepalive', () => { + + const text = 's6-slow-resp'; + const before = allSms.filter(e => e.variant === 'main').length; ++ const release = answerManually(text); + + await sendsms(MAIN_SMSBOX, { from: '46701113311', text, to: '46709771337' }); + + const sms = await waitForSms('main', text); + + await delay(7000); +- await sms.sendResp().catch(() => undefined); ++ await sms.sendResp(); ++ release(); + + // wait-ack-expire defaults to 0x00 (disconnect/reconnect); reconnect-delay is 1s, so give it + // room to rebind and possibly resend the same submit_sm on the new session. +@@ -504,7 +523,6 @@ describe('iv33 variant - interface_version 0x33', () => { + + const sms = await waitForSms('iv33', text); + +- assert.equal((await sms.sendResp()).err, undefined); + await waitForDlrCallback(sms.smsId, '8'); + await sms.sendDlr('DELIVERED'); + await waitForDlrCallback(sms.smsId, '1'); +@@ -518,9 +536,7 @@ describe('maxp1 variant - max-pending-submits 1', () => { + assert.ok(session); + + // max-pending-submits=1 means bearerbox holds the link to one outstanding submit_sm at a +- // time - answer each as it lands, or the whole burst stalls behind the first message. +- session.on('sms', sms => { void sms.sendResp(); }); +- ++ // time - onSms answers each as it lands, or the whole burst stalls behind the first message. + const texts = Array.from({ length: 20 }, (_, i) => `burst-${String(i).padStart(2, '0')}`); + + // Sequential, not Promise.all: concurrent fetch()es reach smsbox's HTTP listener in whatever +@@ -565,7 +581,6 @@ describe('notrx variant - separate TX and RX binds', () => { + const sms = await waitForSms('notrx', text); + + assert.equal(sms.session, tx); +- assert.equal((await sms.sendResp()).err, undefined); + }); + + test('a receipt built on the receiver bind reaches Kannel; the transmitter bind cannot carry one', async () => { +@@ -598,7 +613,6 @@ describe('notrx variant - separate TX and RX binds', () => { + + const sms = await waitForSms('notrx', text); + +- assert.equal((await sms.sendResp()).err, undefined); + await waitForDlrCallback(sms.smsId, '8'); + + const receiptDate = '2609051200'; +diff --git a/interop-tests/php.test.ts b/interop-tests/php.test.ts +index 0415ce9..ac1e6c6 100644 +--- a/interop-tests/php.test.ts ++++ b/interop-tests/php.test.ts +@@ -48,6 +48,15 @@ const { err: serverErr, server: smpp } = await server({ + + return true; + }, ++ // Returning at once answers the message: php-smpp's submit_sm() blocks synchronously reading ++ // the response on the same connection that sent it, so holding the message for a test to ++ // answer would never let that read complete - unlike python-smpplib's driver, this one has no ++ // separate reader thread to poll afterwards. ++ onSms: sms => { ++ const systemId = systemIdBySession.get(sms.session); ++ ++ if (systemId) allSms.push({ sms, systemId }); ++ }, + port: SMPP_PORT, + }); + +@@ -62,18 +71,6 @@ smppServer.on('session', session => { + + systemIdBySession.set(session, paramText(pduObj.params.system_id)); + }); +- +- session.on('sms', sms => { +- const systemId = systemIdBySession.get(session); +- +- if (systemId) allSms.push({ sms, systemId }); +- +- // php-smpp's submit_sm() blocks synchronously reading the response on the same connection +- // that sent it, so answering here (rather than after this event's own test observes the +- // sms) is the only way that read ever completes - unlike python-smpplib's driver, this one +- // has no separate reader thread to poll afterwards. +- void sms.sendResp(); +- }); + }); + + after(async () => { +diff --git a/interop-tests/python.test.ts b/interop-tests/python.test.ts +index 84f1c66..3fb0451 100644 +--- a/interop-tests/python.test.ts ++++ b/interop-tests/python.test.ts +@@ -68,6 +68,11 @@ const { err: serverErr, server: smpp } = await server({ + + return true; + }, ++ onSms: sms => { ++ const systemId = systemIdBySession.get(sms.session); ++ ++ if (systemId) allSms.push({ sms, systemId }); ++ }, + port: SMPP_PORT, + }); + +@@ -82,12 +87,6 @@ smppServer.on('session', session => { + + systemIdBySession.set(session, paramText(pduObj.params.system_id)); + }); +- +- session.on('sms', sms => { +- const systemId = systemIdBySession.get(session); +- +- if (systemId) allSms.push({ sms, systemId }); +- }); + }); + + after(async () => { +@@ -134,7 +133,6 @@ async function waitForReceived(name: string, predicate: (e: ReceivedEntry) => bo + + type AckResult = { messageId?: string; status?: number }; + +-// A single-segment submit_sm is only answered once sendResp() is called on the arrived sms, so + // /submit itself does not wait for the ack (see driver.py) - this polls for it afterwards. + async function waitForAck(name: string, sequence: number, budget = 8000): Promise { + const found = await waitFor(async () => { +@@ -181,9 +179,7 @@ describe('S11 - encodings (python-smpplib)', () => { + + assert.equal(sent.ok, true, JSON.stringify(sent)); + +- const sms = await waitForSms('s11-basic', expectBasic + extensionChars); +- +- assert.equal((await sms.sendResp()).err, undefined); ++ await waitForSms('s11-basic', expectBasic + extensionChars); + assert.equal((await waitForAck('s11-basic', sent.sequence as number)).status, 0); + }); + +@@ -194,9 +190,7 @@ describe('S11 - encodings (python-smpplib)', () => { + + assert.equal(sent.ok, true, JSON.stringify(sent)); + +- const sms = await waitForSms('s11-ff', 'before-\f'); +- +- assert.equal((await sms.sendResp()).err, undefined); ++ await waitForSms('s11-ff', 'before-\f'); + assert.equal((await waitForAck('s11-ff', sent.sequence as number)).status, 0); + }); + +@@ -208,9 +202,7 @@ describe('S11 - encodings (python-smpplib)', () => { + + assert.equal(sent.ok, true, JSON.stringify(sent)); + +- const sms = await waitForSms('s11-latin1', text); +- +- assert.equal((await sms.sendResp()).err, undefined); ++ await waitForSms('s11-latin1', text); + assert.equal((await waitForAck('s11-latin1', sent.sequence as number)).status, 0); + }); + +@@ -222,9 +214,7 @@ describe('S11 - encodings (python-smpplib)', () => { + + assert.equal(sent.ok, true, JSON.stringify(sent)); + +- const sms = await waitForSms('s11-ucs2', text); +- +- assert.equal((await sms.sendResp()).err, undefined); ++ await waitForSms('s11-ucs2', text); + assert.equal((await waitForAck('s11-ucs2', sent.sequence as number)).status, 0); + }); + +@@ -238,7 +228,6 @@ describe('S11 - encodings (python-smpplib)', () => { + + const sms = await waitForSms('s11-echo-gsm', 'seed'); + +- assert.equal((await sms.sendResp()).err, undefined); + assert.equal((await waitForAck('s11-echo-gsm', sent.sequence as number)).status, 0); + + const session = sessionFor('s11-echo-gsm'); +@@ -261,7 +250,6 @@ describe('S11 - encodings (python-smpplib)', () => { + + const sms = await waitForSms('s11-echo-ucs2', seed); + +- assert.equal((await sms.sendResp()).err, undefined); + assert.equal((await waitForAck('s11-echo-ucs2', sent.sequence as number)).status, 0); + + const session = sessionFor('s11-echo-ucs2'); +@@ -288,7 +276,6 @@ describe('S11 - encodings (python-smpplib)', () => { + + const sms = await waitForSms('s11-quirk', '§'); + +- assert.equal((await sms.sendResp()).err, undefined); + assert.equal((await waitForAck('s11-quirk', sent.sequence as number)).status, 0); + + // Sent back the spec-correct way (byte 0x5F again), python's own (non-standard) table reads +@@ -382,8 +369,8 @@ describe('Refusals via onRequest', () => { + + await bindReader(name); + +- // Refused through onRequest, before reassembly and before the sms event, so this is answered +- // without any sendResp() call - unlike every other submit in this file. ++ // Refused through onRequest, before reassembly and before onSms - unlike every other submit ++ // in this file. + const refused = await post('/submit', { dataCoding: 0, from: '46700000001', name, text: 'nope', to: REFUSED_DEST }); + + assert.equal(refused.ok, true, JSON.stringify(refused)); +@@ -397,9 +384,7 @@ describe('Refusals via onRequest', () => { + + assert.equal(after1.ok, true, JSON.stringify(after1)); + +- const sms = await waitForSms(name, 'still works'); +- +- assert.equal((await sms.sendResp()).err, undefined); ++ await waitForSms(name, 'still works'); + assert.equal((await waitForAck(name, after1.sequence as number)).status, 0); + }); + }); +diff --git a/interop-tests/smppsim.test.ts b/interop-tests/smppsim.test.ts +index 8a08817..31425d1 100644 +--- a/interop-tests/smppsim.test.ts ++++ b/interop-tests/smppsim.test.ts +@@ -4,7 +4,7 @@ import type { Dlr } from '../src/dlr.ts'; + import type { EncodingName } from '../src/defs/encodings.ts'; + import type { MessageDlr } from '../src/dlr-merger.ts'; + import type { PduObject } from '../src/pdu.ts'; +-import type { Session } from '../src/session.ts'; ++import type { SmppClient } from '../src/client.ts'; + import type { Sms } from '../src/sms.ts'; + import { client } from '../src/client.ts'; + import { closeAfter } from '../test/teardown.ts'; +@@ -64,22 +64,14 @@ function gsmFiller(targetSeptets: number): string { + /** Delivery-receipt fields the wire actually carried, alongside the parsed Dlr. */ + type Received = { dlr: Dlr; pduObj: PduObject }; + +-function collectDlrs(session: Session): Received[] { ++function collectDlrs(esme: SmppClient): Received[] { + const received: Received[] = []; + +- session.on('dlr', (dlr, pduObj) => { received.push({ dlr, pduObj }); }); ++ esme.on('dlr', (dlr, pduObj) => { received.push({ dlr, pduObj }); }); + + return received; + } + +-function collectSms(session: Session): Sms[] { +- const collected: Sms[] = []; +- +- session.on('sms', sms => { collected.push(sms); }); +- +- return collected; +-} +- + const DLR_RETRY_BUDGET_MS = 3000; + const DLR_MAX_ATTEMPTS = 10; + +@@ -112,13 +104,13 @@ function namedIds(smsIds: (string | undefined)[]): string[] { + * fails here if it keeps missing well past what that alone explains. + */ + async function sendUntilAllDlrsArrive( +- session: Session, ++ esme: SmppClient, + dlrs: Received[], + message: string, + encoding?: EncodingName, + ): Promise { + for (let attempt = 0; attempt < DLR_MAX_ATTEMPTS; attempt++) { +- const sent = await session.sendSms({ ++ const sent = await esme.sendSms({ + dlr: true, + from: FROM, + message, +@@ -143,14 +135,14 @@ async function sendUntilAllDlrsArrive( + + /** As above, but also waits for the loopback deliver_sm(s) to reassemble into the original text. */ + async function sendUntilComplete( +- session: Session, ++ esme: SmppClient, + dlrs: Received[], + sms: Sms[], + message: string, + encoding?: EncodingName, + ): Promise<{ reassembled: Sms; smsIds: string[] }> { + for (let attempt = 0; attempt < DLR_MAX_ATTEMPTS; attempt++) { +- const sent = await session.sendSms({ ++ const sent = await esme.sendSms({ + dlr: true, + from: FROM, + message, +@@ -202,17 +194,17 @@ describe('smppsim - C3+C7 long MT, receipts and loopback reassembly', () => { + + for (const testCase of cases) { + test(testCase.label, async t => { +- const { err, session } = await bind(PEER_HOST); ++ const sms: Sms[] = []; ++ const { err, client: esme } = await bind(PEER_HOST, { onSms: s => { sms.push(s); } }); + + assert.equal(err, undefined); +- assert.ok(session); +- closeAfter(t, session); ++ assert.ok(esme); ++ closeAfter(t, esme); + +- const dlrs = collectDlrs(session); +- const sms = collectSms(session); ++ const dlrs = collectDlrs(esme); + +- const { reassembled, smsIds } = await sendUntilComplete( +- session, ++ const { smsIds } = await sendUntilComplete( ++ esme, + dlrs, + sms, + testCase.message, +@@ -236,22 +228,21 @@ describe('smppsim - C3+C7 long MT, receipts and loopback reassembly', () => { + assert.equal(received.dlr.receipt.err, '000'); + } + +- assert.equal((await reassembled.sendResp()).err, undefined); + }); + } + }); + + describe('smppsim-textdlr - C2 text-only receipts', () => { + test('no TLVs; dlr.smsId parsed from the body matches the submit_sm_resp id', async t => { +- const { err, session } = await bind(TEXTDLR_HOST); ++ const { err, client: esme } = await bind(TEXTDLR_HOST); + + assert.equal(err, undefined); +- assert.ok(session); +- closeAfter(t, session); ++ assert.ok(esme); ++ closeAfter(t, esme); + +- const dlrs = collectDlrs(session); ++ const dlrs = collectDlrs(esme); + +- const sent = await session.sendSms({ dlr: true, from: FROM, message: 'text-only dlr', to: TO }); ++ const sent = await esme.sendSms({ dlr: true, from: FROM, message: 'text-only dlr', to: TO }); + + assert.equal(sent.err, undefined); + assert.equal(sent.smsIds.length, 1); +@@ -272,21 +263,21 @@ describe('smppsim-textdlr - C2 text-only receipts', () => { + + describe('smppsim-transition - C4 intermediate then final', () => { + test('an intermediate report arrives, but registered_delivery 0x11 never gets a final one', async t => { +- const { err, session } = await bind(TRANSITION_HOST); ++ const { err, client: esme } = await bind(TRANSITION_HOST); + + assert.equal(err, undefined); +- assert.ok(session); +- closeAfter(t, session); ++ assert.ok(esme); ++ closeAfter(t, esme); + +- const dlrs = collectDlrs(session); ++ const dlrs = collectDlrs(esme); + const messageDlrs: unknown[] = []; + +- session.on('messageDlr', merged => { messageDlrs.push(merged); }); ++ esme.on('messageDlr', merged => { messageDlrs.push(merged); }); + + // registered_delivery 0x11: final (0x01) + intermediate (0x10). sendSms() only ever + // requests 0x01, so this scenario needs the raw passthrough - which also means + // dlrMerger.expect() is never called, so messageDlr cannot fire here (see findings). +- const sent = await session.send({ ++ const sent = await esme.send({ + cmdName: 'submit_sm', + params: { + data_coding: 0, +@@ -331,14 +322,14 @@ describe('smppsim single-state variants - C5 failure states', () => { + + for (const variant of variants) { + test(`${variant.label} maps to ${variant.expected}`, async t => { +- const { err, session } = await bind(variant.host); ++ const { err, client: esme } = await bind(variant.host); + + assert.equal(err, undefined); +- assert.ok(session); +- closeAfter(t, session); ++ assert.ok(esme); ++ closeAfter(t, esme); + +- const dlrs = collectDlrs(session); +- const sent = await session.sendSms({ dlr: true, from: FROM, message: 'failure state test', to: TO }); ++ const dlrs = collectDlrs(esme); ++ const sent = await esme.sendSms({ dlr: true, from: FROM, message: 'failure state test', to: TO }); + + assert.equal(sent.err, undefined); + +@@ -354,18 +345,18 @@ describe('smppsim single-state variants - C5 failure states', () => { + } + + test('a 2-segment message: both segments report the same status; messageDlr never fires', async t => { +- const { err, session } = await bind(UNDELIV_HOST); ++ const { err, client: esme } = await bind(UNDELIV_HOST); + + assert.equal(err, undefined); +- assert.ok(session); +- closeAfter(t, session); ++ assert.ok(esme); ++ closeAfter(t, esme); + +- const dlrs = collectDlrs(session); ++ const dlrs = collectDlrs(esme); + const messageDlrs: MessageDlr[] = []; + +- session.on('messageDlr', merged => { messageDlrs.push(merged); }); ++ esme.on('messageDlr', merged => { messageDlrs.push(merged); }); + +- const smsIds = await sendUntilAllDlrsArrive(session, dlrs, gsmFiller(200)); ++ const smsIds = await sendUntilAllDlrsArrive(esme, dlrs, gsmFiller(200)); + + assert.equal(smsIds.length, 2); + +@@ -387,21 +378,21 @@ describe('smppsim single-state variants - C5 failure states', () => { + + describe('smppsim-delayed - C6 receipt delayed past a link drop', () => { + test('the merge survives a reconnect; the late receipt still reaches dlr', async t => { +- const { err, session } = await bind(DELAYED_HOST, { reconnect: { maxDelay: 1000, minDelay: 200 } }); ++ const { err, client: esme } = await bind(DELAYED_HOST, { reconnect: { maxDelay: 1000, minDelay: 200 } }); + + assert.equal(err, undefined); +- assert.ok(session); +- closeAfter(t, session); ++ assert.ok(esme); ++ closeAfter(t, esme); + +- const dlrs = collectDlrs(session); ++ const dlrs = collectDlrs(esme); + const disconnected: true[] = []; + const reconnected: true[] = []; + +- session.on('disconnected', () => { disconnected.push(true); }); +- session.on('reconnected', () => { reconnected.push(true); }); ++ esme.on('disconnected', () => { disconnected.push(true); }); ++ esme.on('reconnected', () => { reconnected.push(true); }); + + const sendStart = Date.now(); +- const sent = await session.sendSms({ dlr: true, from: FROM, message: 'delayed dlr test', to: TO }); ++ const sent = await esme.sendSms({ dlr: true, from: FROM, message: 'delayed dlr test', to: TO }); + + assert.equal(sent.err, undefined); + +@@ -410,7 +401,8 @@ describe('smppsim-delayed - C6 receipt delayed past a link drop', () => { + assert.ok(smsId); + + await delay(300); +- session.sock.destroy(); ++ assert.ok(esme.session); ++ esme.session.sock.destroy(); + + assert.ok(await waitFor(() => (disconnected.length > 0 ? true : undefined), 2000), 'expected disconnected'); + assert.ok(await waitFor(() => (reconnected.length > 0 ? true : undefined), 4000), 'expected reconnected'); +@@ -439,20 +431,20 @@ describe('smppsim - C11 bind refusal and reconnect backoff', () => { + warn: () => undefined, + }; + +- const { err, session } = await bind(PEER_HOST, { log, password: 'wrong', reconnect: { maxDelay: 4000, minDelay: 1000 } }); ++ const { err, client: esme } = await bind(PEER_HOST, { log, password: 'wrong', reconnect: { maxDelay: 4000, minDelay: 1000 } }); + + assert.ok(err); +- assert.equal(session, undefined); ++ assert.equal(esme, undefined); + await delay(2000); + assert.equal(refusals.length, 1, 'expected exactly one bind attempt, never a retry'); + assert.equal(refusals[0]?.cmdStatus, 'ESME_RINVPASWD'); + }); + + test('a closed port on the very first connect: one attempt, no retry', async () => { +- const { err, session } = await bind(PEER_HOST, { port: 46775, reconnect: { maxDelay: 4000, minDelay: 1000 } }); ++ const { err, client: esme } = await bind(PEER_HOST, { port: 46775, reconnect: { maxDelay: 4000, minDelay: 1000 } }); + + assert.ok(err); +- assert.equal(session, undefined); ++ assert.equal(esme, undefined); + }); + + test('a rebind refused after a live link drops: backs off, never floods', async t => { +@@ -463,19 +455,20 @@ describe('smppsim - C11 bind refusal and reconnect backoff', () => { + reconnect: { maxDelay: 4000, minDelay: 1000 }, + username: USERNAME, + }; +- const { err, session } = await client(options); ++ const { err, client: esme } = await client(options); + + assert.equal(err, undefined); +- assert.ok(session); +- closeAfter(t, session); ++ assert.ok(esme); ++ closeAfter(t, esme); + + const disconnectedAt: number[] = []; + +- session.on('disconnected', () => { disconnectedAt.push(Date.now()); }); +- // Mutates the same object reference the reconnect loop's onConnected closure reads, so +- // every rebind attempt from here on is refused - see interop-tests/findings/02-smppsim.md. ++ esme.on('disconnected', () => { disconnectedAt.push(Date.now()); }); ++ // Mutates the same object reference the reconnect loop's open() reads, so every rebind ++ // attempt from here on is refused - see interop-tests/findings/02-smppsim.md. + options.password = 'wrong-after-drop'; +- session.sock.destroy(); ++ assert.ok(esme.session); ++ esme.session.sock.destroy(); + + await delay(12_000); + +@@ -493,32 +486,32 @@ describe('smppsim - C11 bind refusal and reconnect backoff', () => { + + describe('smppsim-queuefull - C12 ESME_RMSGQFUL', () => { + test('refuses when the queue is full; the session stays bound; a later send succeeds once it drains', async t => { +- const { err, session } = await bind(QUEUEFULL_HOST); ++ const { err, client: esme } = await bind(QUEUEFULL_HOST); + + assert.equal(err, undefined); +- assert.ok(session); +- closeAfter(t, session); ++ assert.ok(esme); ++ closeAfter(t, esme); + +- const first = await session.sendSms({ from: FROM, message: 'occupies the one queue slot', to: TO }); ++ const first = await esme.sendSms({ from: FROM, message: 'occupies the one queue slot', to: TO }); + + assert.equal(first.err, undefined); + +- const second = await session.sendSms({ from: FROM, message: 'should be refused', to: TO }); ++ const second = await esme.sendSms({ from: FROM, message: 'should be refused', to: TO }); + + assert.ok(second.err); + assert.match(second.err.message, /ESME_RMSGQFUL/); + +- const keepalive = await session.send({ cmdName: 'enquire_link' }); ++ const keepalive = await esme.send({ cmdName: 'enquire_link' }); + + assert.equal(keepalive.err, undefined); + assert.ok(keepalive.pduObj); + assert.equal(keepalive.pduObj.cmdStatus, 'ESME_ROK'); + + const deadline = Date.now() + 5000; +- let drained: Awaited> | undefined; ++ let drained: Awaited> | undefined; + + while (!drained && Date.now() < deadline) { +- const attempt = await session.sendSms({ from: FROM, message: 'after drain', to: TO }); ++ const attempt = await esme.sendSms({ from: FROM, message: 'after drain', to: TO }); + + if (!attempt.err) drained = attempt; + else await delay(100); +@@ -530,14 +523,14 @@ describe('smppsim-queuefull - C12 ESME_RMSGQFUL', () => { + + describe('smppsim - C13 maxOutstanding 1 with 10 parallel sends', () => { + test('every send is answered, in order, none lost', async t => { +- const { err, session } = await bind(PEER_HOST, { maxOutstanding: 1 }); ++ const { err, client: esme } = await bind(PEER_HOST, { maxOutstanding: 1 }); + + assert.equal(err, undefined); +- assert.ok(session); +- closeAfter(t, session); ++ assert.ok(esme); ++ closeAfter(t, esme); + + const results = await Promise.all( +- Array.from({ length: 10 }, async (_unused, index) => session.sendSms({ ++ Array.from({ length: 10 }, async (_unused, index) => esme.sendSms({ + from: FROM, + message: `order test ${String(index)}`, + to: TO, +@@ -571,32 +564,33 @@ describe('smppsim - C13 maxOutstanding 1 with 10 parallel sends', () => { + describe('smppsim - C15 bind version negotiation', () => { + for (const interfaceVersion of [0x34, 0x50]) { + test(`interfaceVersion 0x${interfaceVersion.toString(16)}: SMPPSim's bind_resp never declares a version`, async t => { +- const { err, session } = await bind(PEER_HOST, { interfaceVersion }); ++ const { err, client: esme } = await bind(PEER_HOST, { interfaceVersion }); + + assert.equal(err, undefined); +- assert.ok(session); +- closeAfter(t, session); ++ assert.ok(esme); ++ closeAfter(t, esme); + + // Confirmed from source (no BindXResp class ever sets sc_interface_version): SMPPSim + // never declares its own version, whatever we declared - so acceptsOptionalParams() + // reads it as pre-3.4, even though it happily sends us TLVs (see C3). +- assert.equal(session.peerInterfaceVersion, 0x00); +- assert.equal(session.acceptsOptionalParams(), false); ++ assert.ok(esme.session); ++ assert.equal(esme.session.peerInterfaceVersion, 0x00); ++ assert.equal(esme.session.acceptsOptionalParams(), false); + }); + } + }); + + describe('smppsim - C17 encodings round trip over loopback', () => { + test('Latin-1 (å ä ö)', async t => { +- const { err, session } = await bind(PEER_HOST); ++ const sms: Sms[] = []; ++ const { err, client: esme } = await bind(PEER_HOST, { onSms: s => { sms.push(s); } }); + + assert.equal(err, undefined); +- assert.ok(session); +- closeAfter(t, session); ++ assert.ok(esme); ++ closeAfter(t, esme); + +- const sms = collectSms(session); + +- await session.sendSms({ encoding: 'LATIN1', from: FROM, message: 'å ä ö', to: TO }); ++ await esme.sendSms({ encoding: 'LATIN1', from: FROM, message: 'å ä ö', to: TO }); + + const received = await waitFor(() => sms.find(s => s.message === 'å ä ö')); + +@@ -605,15 +599,15 @@ describe('smppsim - C17 encodings round trip over loopback', () => { + }); + + test('UCS-2', async t => { +- const { err, session } = await bind(PEER_HOST); ++ const sms: Sms[] = []; ++ const { err, client: esme } = await bind(PEER_HOST, { onSms: s => { sms.push(s); } }); + + assert.equal(err, undefined); +- assert.ok(session); +- closeAfter(t, session); ++ assert.ok(esme); ++ closeAfter(t, esme); + +- const sms = collectSms(session); + +- await session.sendSms({ encoding: 'UCS2', from: FROM, message: 'ucs2 round trip', to: TO }); ++ await esme.sendSms({ encoding: 'UCS2', from: FROM, message: 'ucs2 round trip', to: TO }); + + const received = await waitFor(() => sms.find(s => s.message === 'ucs2 round trip')); + +@@ -622,15 +616,15 @@ describe('smppsim - C17 encodings round trip over loopback', () => { + }); + + test('flash (data_coding records the message-class group)', async t => { +- const { err, session } = await bind(PEER_HOST); ++ const sms: Sms[] = []; ++ const { err, client: esme } = await bind(PEER_HOST, { onSms: s => { sms.push(s); } }); + + assert.equal(err, undefined); +- assert.ok(session); +- closeAfter(t, session); ++ assert.ok(esme); ++ closeAfter(t, esme); + +- const sms = collectSms(session); + +- await session.sendSms({ flash: true, from: FROM, message: 'flash test', to: TO }); ++ await esme.sendSms({ flash: true, from: FROM, message: 'flash test', to: TO }); + + const received = await waitFor(() => sms.find(s => s.message === 'flash test')); + +@@ -640,16 +634,16 @@ describe('smppsim - C17 encodings round trip over loopback', () => { + }); + + test('a raw submit_sm with data_coding 0xF0 is read as flash', async t => { +- const { err, session } = await bind(PEER_HOST); ++ const sms: Sms[] = []; ++ const { err, client: esme } = await bind(PEER_HOST, { onSms: s => { sms.push(s); } }); + + assert.equal(err, undefined); +- assert.ok(session); +- closeAfter(t, session); ++ assert.ok(esme); ++ closeAfter(t, esme); + +- const sms = collectSms(session); + const body = 'message class test'; + +- const sent = await session.send({ ++ const sent = await esme.send({ + cmdName: 'submit_sm', + params: { + data_coding: 0xF0, +@@ -668,13 +662,13 @@ describe('smppsim - C17 encodings round trip over loopback', () => { + }); + + test('a raw submit_sm with 8-bit binary and a UDH (esm_class 0x40)', async t => { +- const { err, session } = await bind(PEER_HOST); ++ const sms: Sms[] = []; ++ const { err, client: esme } = await bind(PEER_HOST, { onSms: s => { sms.push(s); } }); + + assert.equal(err, undefined); +- assert.ok(session); +- closeAfter(t, session); ++ assert.ok(esme); ++ closeAfter(t, esme); + +- const sms = collectSms(session); + // A UDH carrying no recognised concatenation IE (0x00/0x08): one element in GSM 03.40's + // reserved-for-future-use range (0x70), so Wireshark's gsm_sms_ud dissector - which + // validates the *typed* IEs' own lengths (0x01 "Special SMS Message Indication" must be +@@ -682,7 +676,7 @@ describe('smppsim - C17 encodings round trip over loopback', () => { + const udh = Buffer.from([0x03, 0x70, 0x01, 0xAA]); + const payload = Buffer.from([0xDE, 0xAD, 0xBE, 0xEF]); + +- const sent = await session.send({ ++ const sent = await esme.send({ + cmdName: 'submit_sm', + params: { + data_coding: consts.ENCODING.BINARY, +diff --git a/interop-tests/smscsim.test.ts b/interop-tests/smscsim.test.ts +index ba23042..26b84ab 100644 +--- a/interop-tests/smscsim.test.ts ++++ b/interop-tests/smscsim.test.ts +@@ -1,7 +1,7 @@ + import assert from 'node:assert/strict'; + import test, { describe } from 'node:test'; + import type { Dlr } from '../src/dlr.ts'; +-import type { Session } from '../src/session.ts'; ++import type { SmppClient } from '../src/client.ts'; + import type { Sms } from '../src/sms.ts'; + import { client } from '../src/client.ts'; + import { closeAfter } from '../test/teardown.ts'; +@@ -33,8 +33,8 @@ async function waitFor(get: () => T | undefined, budget = 5000): Promise { +- const sent = await session.sendSms({ dlr: true, from: '46701113311', message, to: '46709771337' }); ++async function sendAndAwaitDlrs(esme: SmppClient, dlrs: Dlr[], message: string): Promise { ++ const sent = await esme.sendSms({ dlr: true, from: '46701113311', message, to: '46709771337' }); + + assert.equal(sent.err, undefined); + +@@ -57,7 +57,7 @@ describe('smscsim - C1 bind, keepalive, unbind', () => { + const closes: unknown[] = []; + const sessionErrors: Error[] = []; + +- const { err, session } = await client({ ++ const { err, client: esme } = await client({ + bindType, + enquireLinkInterval: 1000, + host: PEER_HOST, +@@ -66,16 +66,16 @@ describe('smscsim - C1 bind, keepalive, unbind', () => { + }); + + assert.equal(err, undefined); +- assert.ok(session); +- closeAfter(t, session); ++ assert.ok(esme); ++ closeAfter(t, esme); + +- session.on('close', () => { closes.push(undefined); }); +- session.on('sessionError', sessionError => { sessionErrors.push(sessionError); }); ++ esme.on('close', () => { closes.push(undefined); }); ++ esme.on('sessionError', sessionError => { sessionErrors.push(sessionError); }); + + // smscsim never sends an unsolicited enquire_link of its own - its ENQUIRE_LINK case + // only answers one (confirmed in its source, smsc.go). So the interval-driven + // keepalive is checked through its own response, not through `incomingPduObj`. +- const enquired = await session.send({ cmdName: 'enquire_link' }); ++ const enquired = await esme.send({ cmdName: 'enquire_link' }); + + assert.equal(enquired.err, undefined); + assert.ok(enquired.pduObj); +@@ -84,7 +84,7 @@ describe('smscsim - C1 bind, keepalive, unbind', () => { + + await delay(1500); + +- const unbound = await session.unbind(); ++ const unbound = await esme.unbind(); + + assert.equal(unbound.err, undefined); + +@@ -100,15 +100,15 @@ describe('smscsim - a single SMS', () => { + test('one id back, a DLR within 5s naming it DELIVERED', async t => { + const dlrs: Dlr[] = []; + +- const { err, session } = await client({ host: PEER_HOST, port: PEER_PORT, username: 'single-sms' }); ++ const { err, client: esme } = await client({ host: PEER_HOST, port: PEER_PORT, username: 'single-sms' }); + + assert.equal(err, undefined); +- assert.ok(session); +- closeAfter(t, session); ++ assert.ok(esme); ++ closeAfter(t, esme); + +- session.on('dlr', dlr => { dlrs.push(dlr); }); ++ esme.on('dlr', dlr => { dlrs.push(dlr); }); + +- const smsIds = await sendAndAwaitDlrs(session, dlrs, 'hello world'); ++ const smsIds = await sendAndAwaitDlrs(esme, dlrs, 'hello world'); + + assert.equal(smsIds.length, 1); + +@@ -127,17 +127,17 @@ describe('smscsim - multipart segments', () => { + test('a 2-segment GSM message gets 2 ids and a DLR per id', async t => { + const dlrs: Dlr[] = []; + +- const { err, session } = await client({ host: PEER_HOST, port: PEER_PORT, username: 'gsm-multipart' }); ++ const { err, client: esme } = await client({ host: PEER_HOST, port: PEER_PORT, username: 'gsm-multipart' }); + + assert.equal(err, undefined); +- assert.ok(session); +- closeAfter(t, session); ++ assert.ok(esme); ++ closeAfter(t, esme); + +- session.on('dlr', dlr => { dlrs.push(dlr); }); ++ esme.on('dlr', dlr => { dlrs.push(dlr); }); + + // 200 plain GSM chars: over the 160-char single-segment budget, under the 306-char + // 2-segment one (153 septets each). +- const smsIds = await sendAndAwaitDlrs(session, dlrs, 'a'.repeat(200)); ++ const smsIds = await sendAndAwaitDlrs(esme, dlrs, 'a'.repeat(200)); + + assert.equal(smsIds.length, 2); + }); +@@ -145,38 +145,36 @@ describe('smscsim - multipart segments', () => { + test('a 2-segment UCS2 message (一 and an emoji) gets 2 ids and a DLR per id', async t => { + const dlrs: Dlr[] = []; + +- const { err, session } = await client({ host: PEER_HOST, port: PEER_PORT, username: 'ucs2-multipart' }); ++ const { err, client: esme } = await client({ host: PEER_HOST, port: PEER_PORT, username: 'ucs2-multipart' }); + + assert.equal(err, undefined); +- assert.ok(session); +- closeAfter(t, session); ++ assert.ok(esme); ++ closeAfter(t, esme); + +- session.on('dlr', dlr => { dlrs.push(dlr); }); ++ esme.on('dlr', dlr => { dlrs.push(dlr); }); + + // 一 (2 bytes) + an emoji (a surrogate pair, 4 bytes) + 70 padding chars (2 bytes each): + // 146 bytes, over the 140-byte single-segment budget, under the 268-byte 2-segment one. +- const smsIds = await sendAndAwaitDlrs(session, dlrs, `一😀${'x'.repeat(70)}`); ++ const smsIds = await sendAndAwaitDlrs(esme, dlrs, `一😀${'x'.repeat(70)}`); + + assert.equal(smsIds.length, 2); + }); + }); + + describe('smscsim - MO injection through the web UI', () => { +- test('a message posted to the web page arrives as an sms event', async t => { +- const { err, session } = await client({ ++ test('a message posted to the web page reaches onSms', async t => { ++ const incoming: Sms[] = []; ++ const { err, client: esme } = await client({ + bindType: 'transceiver', + host: PEER_HOST, ++ onSms: sms => { incoming.push(sms); }, + port: PEER_PORT, + username: 'mo-inject', + }); + + assert.equal(err, undefined); +- assert.ok(session); +- closeAfter(t, session); +- +- const incoming: Sms[] = []; +- +- session.on('sms', sms => { incoming.push(sms); }); ++ assert.ok(esme); ++ closeAfter(t, esme); + + const response = await fetch(`http://${PEER_HOST}:${String(PEER_WEB_PORT)}/`, { + body: new URLSearchParams({ +@@ -199,7 +197,6 @@ describe('smscsim - MO injection through the web UI', () => { + assert.equal(sms.to, '46709771337'); + assert.equal(sms.message, 'hello from the web UI'); + +- assert.equal((await sms.sendResp()).err, undefined); + }); + }); + +@@ -207,13 +204,13 @@ describe('smscsim-failing - C12 refusals', () => { + test('even sequence numbers are refused, odd ones get an undeliverable DLR', async t => { + const dlrs: Dlr[] = []; + +- const { err, session } = await client({ host: FAILING_PEER_HOST, port: FAILING_PEER_PORT, username: 'c12' }); ++ const { err, client: esme } = await client({ host: FAILING_PEER_HOST, port: FAILING_PEER_PORT, username: 'c12' }); + + assert.equal(err, undefined); +- assert.ok(session); +- closeAfter(t, session); ++ assert.ok(esme); ++ closeAfter(t, esme); + +- session.on('dlr', dlr => { dlrs.push(dlr); }); ++ esme.on('dlr', dlr => { dlrs.push(dlr); }); + + let refusedSeen = false; + let acceptedConfirmed = false; +@@ -222,7 +219,7 @@ describe('smscsim-failing - C12 refusals', () => { + const before = dlrs.length; + + // Sequential: smscsim keys its refusal on each submit_sm's own sequence number parity. +- const result = await session.sendSms({ ++ const result = await esme.sendSms({ + dlr: true, + from: '46701113311', + message: `refusal check ${String(attempt)}`, +@@ -253,7 +250,7 @@ describe('smscsim-failing - C12 refusals', () => { + assert.ok(acceptedConfirmed, 'no accepted send got a confirmed undeliverable DLR'); + + // The session must stay bound and usable after a refusal. +- const enquired = await session.send({ cmdName: 'enquire_link' }); ++ const enquired = await esme.send({ cmdName: 'enquire_link' }); + + assert.equal(enquired.err, undefined); + }); +diff --git a/src/client.ts b/src/client.ts +index 6b7299c..5031634 100644 +--- a/src/client.ts ++++ b/src/client.ts +@@ -1,21 +1,31 @@ + import type { ConnectionOptions } from 'node:tls'; ++import type { Dlr } from './dlr.ts'; ++import type { MessageDlr } from './dlr-merger.ts'; ++import type { PduObject, PduObjectInput } from './pdu.ts'; ++import type { PduRefusedError } from './pdu-refusal.ts'; + import type { Result, VoidResult } from './result.ts'; +-import type { BindType, ReconnectOptions } from './session-options.ts'; ++import type { BindType, CloseOptions, OnSms, SendOptions } from './session-options.ts'; ++import type { SendSmsOptions, SendSmsResult } from './send-sms.ts'; + import type { SmppLog } from './log.ts'; + import type { SmsIdFormat } from './sms-id.ts'; + import type { Socket } from 'node:net'; +-export type { BindType }; +- ++import { EventEmitter } from 'node:events'; ++import { DlrMerger } from './dlr-merger.ts'; ++import { LinkLostError } from './link-lost-error.ts'; + import { ReconnectLoop } from './reconnect-loop.ts'; + import { Session } from './session.ts'; + import { checkSessionOptions } from './session-options.ts'; + import { connect as netConnect } from 'node:net'; + import { connect as tlsConnect } from 'node:tls'; +-import { defaultInterfaceVersion } from './defs/constants.ts'; ++import { defaults } from './defaults.ts'; ++import { errorFrom } from './error-from.ts'; + import { guardedLog } from './log.ts'; ++import { leftOf } from './idle-waiters.ts'; ++import { submitSms, unsent } from './send-sms.ts'; ++import { ConcatReference } from './udh.ts'; + + /** `fromStart` puts the very first connect and bind through the same backoff loop as a drop. */ +-type ReconnectTuning = { fromStart?: boolean; maxDelay?: number; minDelay?: number }; ++export type ReconnectTuning = { fromStart?: boolean; maxDelay?: number; minDelay?: number }; + + export type ClientOptions = { + addressRange?: string; +@@ -29,6 +39,7 @@ export type ClientOptions = { + interfaceVersion?: number; + log?: SmppLog; + maxOutstanding?: number; ++ onSms?: OnSms; + password?: string; + port?: number; + reconnect?: ReconnectTuning | false; +@@ -41,18 +52,204 @@ export type ClientOptions = { + username?: string; + }; + +-const defaults = { +- bindType: 'transceiver', +- connectTimeout: 10_000, +- enquireLinkInterval: 20_000, +- host: 'localhost', +- /** The idle timeout is what notices a dead link, so it has to outlast one silent probe. */ +- idleTimeoutFactor: 2, +- interfaceVersion: defaultInterfaceVersion, +- password: 'pass', +- port: 2775, +- username: 'user', +-} as const; ++export type ClientEvents = { ++ close: []; ++ disconnected: []; ++ dlr: [Dlr, PduObject]; ++ messageDlr: [MessageDlr]; ++ reconnected: []; ++ sessionError: [Error | PduRefusedError]; ++}; ++ ++/** A listener may return a promise: an `async` one that rejects is routed like one that throws. */ ++type ClientListener = (...args: ClientEvents[K]) => unknown; ++ ++/** ++ * The ESME's stable handle: one bound session at a time, opened again after a drop, with the ++ * sends waiting for a link and the receipt merges that outlive any one session. ++ */ ++export class SmppClient extends EventEmitter { ++ declare addListener: (event: K, listener: ClientListener) => this; ++ declare off: (event: K, listener: ClientListener) => this; ++ declare on: (event: K, listener: ClientListener) => this; ++ declare once: (event: K, listener: ClientListener) => this; ++ declare prependListener: (event: K, listener: ClientListener) => this; ++ declare prependOnceListener: (event: K, listener: ClientListener) => this; ++ declare removeListener: (event: K, listener: ClientListener) => this; ++ ++ readonly log: SmppLog; ++ ++ private closed = false; ++ private readonly concatReference = new ConcatReference(); ++ private readonly links: ReconnectLoop; ++ private readonly merger: DlrMerger; ++ private readonly options: ClientOptions; ++ private readonly responseTimeout: number; ++ ++ /** A listener that throws is the application's bug; it must not become ours. Hard rule 1. */ ++ override emit( ++ event: K, ++ ...args: K extends keyof ClientEvents ? ClientEvents[K] : never ++ ): boolean { ++ try { ++ return super.emit(event, ...args); ++ } catch (thrown: unknown) { ++ const err = errorFrom(thrown); ++ ++ this.log.error('client - a listener threw', { event, message: err.message }); ++ ++ if (event !== 'sessionError') this.emit('sessionError', err); ++ ++ return false; ++ } ++ } ++ ++ override [EventEmitter.captureRejectionSymbol]( ++ reason: unknown, ++ ...args: [event: keyof ClientEvents, ...rest: unknown[]] ++ ): void { ++ const [event] = args; ++ const error = errorFrom(reason); ++ ++ this.log.error('client - a listener rejected', { event, message: error.message }); ++ ++ if (event !== 'sessionError') this.emit('sessionError', error); ++ } ++ ++ constructor(options: ClientOptions, log: SmppLog) { ++ super({ captureRejections: true }); ++ ++ this.log = log; ++ this.options = options; ++ this.responseTimeout = options.responseTimeout ?? defaults.responseTimeout; ++ this.merger = new DlrMerger({ log, max: defaults.maxDlrMerges, timeout: defaults.dlrMergeTimeout }); ++ this.links = new ReconnectLoop({ ++ log, ++ maxDelay: options.reconnect ? options.reconnect.maxDelay : undefined, ++ minDelay: options.reconnect ? options.reconnect.minDelay : undefined, ++ onDown: retrying => { this.down(retrying); }, ++ onUp: (session, first) => { this.up(session, first); }, ++ open: () => openBoundSession(options, log), ++ }); ++ options.signal?.addEventListener('abort', () => { void this.close({ signal: options.signal }); }, { once: true }); ++ } ++ ++ /** The bound session, or undefined while the link is down. Replaced on every reconnect. */ ++ get session(): Session | undefined { ++ return this.links.current(); ++ } ++ ++ /** Sends a request on the bound link, waiting for one up to `responseTimeout`, and resolves with the response. */ ++ async send(input: PduObjectInput, options: SendOptions = {}): Promise> { ++ const deadline = this.responseTimeout > 0 ? Date.now() + this.responseTimeout : 0; ++ ++ for (;;) { ++ const link = await this.links.bound(leftOf(deadline), options.signal); ++ ++ if (link.err) return { err: link.err }; ++ ++ const sent = await link.session.send(input, options); ++ ++ // Nothing reached the socket, so the next link carries it; anything else is the answer. ++ if (!(sent.err instanceof LinkLostError)) return sent; ++ } ++ } ++ ++ async sendSms(sms: SendSmsOptions, options: SendOptions = {}): Promise { ++ if ((this.options.bindType ?? defaults.bindType) === 'receiver') { ++ return unsent(new Error('A receiver-bound session does not carry submit_sm')); ++ } ++ ++ const sent = await submitSms({ ++ log: this.log, ++ reference: this.concatReference.next(), ++ respIdNotation: this.options.smsIdFormat?.submitResp, ++ send: input => this.send(input, options), ++ }, sms); ++ ++ if (!sent.err && sms.dlr === true) this.merger.expect(sent.smsIds); ++ ++ return sent; ++ } ++ ++ /** Drains and unbinds the bound session, and opens no other. */ ++ async unbind(): Promise { ++ const session = this.links.current(); ++ ++ this.links.stop(); ++ ++ const unbound = session ? await session.unbind() : { err: new Error('Session is closed') }; ++ ++ this.end(); ++ ++ return unbound; ++ } ++ ++ /** Drains and closes the bound session, and opens no other. */ ++ async close(options: CloseOptions = {}): Promise { ++ const session = this.links.current(); ++ ++ this.links.stop(); ++ ++ const closed = session ? await session.close(options) : {}; ++ ++ this.end(); ++ ++ return closed; ++ } ++ ++ /** The first session is the caller's to take; every later one is announced. */ ++ takeFirst(session: Session): void { ++ this.links.adopt(session); ++ ++ if (this.options.reconnect === false) this.links.stop(); ++ } ++ ++ /** Keeps trying, on the backoff, until a link binds or the signal ends the wait. */ ++ async keepTrying(): Promise { ++ this.links.schedule(); ++ ++ const link = await this.links.bound(0, this.options.signal); ++ ++ if (link.err) this.links.stop(); ++ ++ return link.err ? { err: link.err } : {}; ++ } ++ ++ private up(session: Session, first: boolean): void { ++ session.on('dlr', (dlr, pduObj) => { ++ this.emit('dlr', dlr, pduObj); ++ ++ const merged = this.merger.collect(dlr); ++ ++ if (merged) this.emit('messageDlr', merged); ++ }); ++ session.on('sessionError', err => { this.emit('sessionError', err); }); ++ ++ if (first) return; ++ ++ this.log.info('client - reconnected'); ++ this.emit('reconnected'); ++ } ++ ++ private down(retrying: boolean): void { ++ if (retrying) { ++ this.emit('disconnected'); ++ ++ return; ++ } ++ ++ this.end(); ++ } ++ ++ private end(): void { ++ if (this.closed) return; ++ ++ this.closed = true; ++ this.merger.clear(); ++ this.emit('close'); ++ } ++} + + function armConnectTimeout( + sock: Socket, +@@ -97,7 +294,7 @@ function openSocket(options: ClientOptions): Promise> { + try { + sock = secure ? tlsConnect({ host, port, ...tlsOptions }) : netConnect({ host, port }); + } catch (thrown: unknown) { +- resolve({ err: thrown instanceof Error ? thrown : new Error(String(thrown)) }); ++ resolve({ err: errorFrom(thrown) }); + + return; + } +@@ -128,21 +325,6 @@ function openSocket(options: ClientOptions): Promise> { + }); + } + +-/** Every connect this client makes goes through here, so a failed one is named the same way once. */ +-async function connectSocket(options: ClientOptions, log: SmppLog): Promise> { +- const opened = await openSocket(options); +- +- if (opened.err) { +- log.warn('client - could not connect', { +- host: options.host ?? defaults.host, +- message: opened.err.message, +- port: options.port ?? defaults.port, +- }); +- } +- +- return opened; +-} +- + function bindParams(options: ClientOptions, systemId: string) { + return { + address_range: options.addressRange ?? '', +@@ -183,19 +365,6 @@ async function bind(session: Session, options: ClientOptions): Promise connectSocket(options, log), +- maxDelay: tuning.maxDelay, +- minDelay: tuning.minDelay, +- onConnected: reconnected => bind(reconnected, options), +- }; +-} +- + function createSession(options: ClientOptions, log: SmppLog, sock: Socket): Session { + const enquireLinkInterval = options.enquireLinkInterval ?? defaults.enquireLinkInterval; + +@@ -204,7 +373,7 @@ function createSession(options: ClientOptions, log: SmppLog, sock: Socket): Sess + idleTimeout: options.idleTimeout ?? enquireLinkInterval * defaults.idleTimeoutFactor, + log, + maxOutstanding: options.maxOutstanding, +- reconnect: reconnectFor(options, log), ++ onSms: options.onSms, + responseTimeout: options.responseTimeout, + shutdownTimeout: options.shutdownTimeout, + smsIdFormat: options.smsIdFormat, +@@ -212,41 +381,25 @@ function createSession(options: ClientOptions, log: SmppLog, sock: Socket): Sess + }); + } + +-async function connectAndBind( +- options: ClientOptions, +- log: SmppLog, +-): Promise> { +- const opened = await connectSocket(options, log); +- +- if (opened.err) return { err: opened.err }; +- +- return bindOn(createSession(options, log, opened.sock), options); +-} +- +-/** Binds a session the caller has not seen yet, so a failure takes it down instead of surfacing. */ +-async function bindOn( +- session: Session, +- options: ClientOptions, +-): Promise> { +- const signal = options.signal; ++/** Every link this client opens goes through here: one socket, one session, one bind. */ ++async function openBoundSession(options: ClientOptions, log: SmppLog): Promise> { ++ const opened = await openSocket(options); + +- if (signal?.aborted === true) { +- void session.close({ signal }); ++ if (opened.err) { ++ log.warn('client - could not connect', { ++ host: options.host ?? defaults.host, ++ message: opened.err.message, ++ port: options.port ?? defaults.port, ++ }); + +- return { err: new Error('Aborted before binding') }; ++ return { err: opened.err }; + } + +- const onAbort = (): void => { void session.close({ signal }); }; +- +- // Registered before the bind: an abort landing while it is in flight has to close the session. +- signal?.addEventListener('abort', onAbort, { once: true }); +- ++ const session = createSession(options, log, opened.sock); + const bound = await bind(session, options); + + if (bound.err) { +- signal?.removeEventListener('abort', onAbort); +- // close() must reach the loop's stop() before its first await, or this session retries too. +- void session.close({ signal }); ++ await session.close({ signal: AbortSignal.abort() }); + + return { err: bound.err }; + } +@@ -254,82 +407,12 @@ async function bindOn( + return { session }; + } + +-function retriesFromStart(reconnect: ClientOptions['reconnect']): reconnect is ReconnectTuning { ++function retriesFromStart(reconnect: ClientOptions['reconnect']): boolean { + return reconnect !== undefined && reconnect !== false && reconnect.fromStart === true; + } + +-/** A fresh session per attempt, and the failure to answer an abort with when none of them binds. */ +-function initialAttempts(options: ClientOptions, log: SmppLog, failed: Error) { +- let lastErr = failed; +- +- return { +- bind: async (sock: Socket): Promise> => { +- const bound = await bindOn(createSession(options, log, sock), options); +- +- if (bound.err) lastErr = bound.err; +- +- return bound; +- }, +- connect: async (): Promise> => { +- const opened = await connectSocket(options, log); +- +- if (opened.err) lastErr = opened.err; +- +- return opened; +- }, +- lastErr: (): Error => lastErr, +- }; +-} +- +-/** Retries the first connect and bind, on the backoff a drop takes, until one of them binds. */ +-function keepTrying( +- options: ClientOptions, +- log: SmppLog, +- tuning: ReconnectTuning, +- failed: Error, +-): Promise> { +- return new Promise(resolve => { +- const attempts = initialAttempts(options, log, failed); +- const signal = options.signal; +- let settled = false; +- const loop = new ReconnectLoop({ +- connect: attempts.connect, +- log, +- maxDelay: tuning.maxDelay, +- minDelay: tuning.minDelay, +- onConnected: async sock => { +- const bound = await attempts.bind(sock); +- +- if (bound.err) return { err: bound.err }; +- +- settle({ session: bound.session }); +- +- return {}; +- }, +- // Awaited with no other handle, so an unref()'d wait would exit the process unbound. +- unref: false, +- }); +- +- function settle(result: Result<{ session: Session }>): void { +- if (settled) return; +- +- settled = true; +- loop.stop(); +- signal?.removeEventListener('abort', onAbort); +- resolve(result); +- } +- +- function onAbort(): void { +- settle({ err: new Error('Aborted while connecting', { cause: attempts.lastErr() }) }); +- } +- +- signal?.addEventListener('abort', onAbort, { once: true }); +- loop.schedule(); +- }); +-} +- + /** Connects to an SMSC and binds. */ +-export async function client(options: ClientOptions = {}): Promise> { ++export async function client(options: ClientOptions = {}): Promise> { + const log = guardedLog(options.log); + const checked = checkSessionOptions(options); + +@@ -339,10 +422,18 @@ export async function client(options: ClientOptions = {}): Promise = { +- ASCII: ascii, ++ ASCII: gsm0338, + LATIN1: latin1, + UCS2: ucs2, + }; +diff --git a/src/dlr-merger.ts b/src/dlr-merger.ts +index 0c20cae..ac282af 100644 +--- a/src/dlr-merger.ts ++++ b/src/dlr-merger.ts +@@ -69,13 +69,22 @@ export class DlrMerger { + private readonly groups: ExpiringGroups; + private readonly log: SmppLog; + private readonly max: number; ++ /** The bases already merged or given up on, so none is opened twice. */ + private readonly spent: ExpiringGroups; + + constructor(options: DlrMergerOptions) { + this.groups = new ExpiringGroups({ + max: options.max, + now: options.now, +- onSweep: () => { this.sweep(); }, ++ onDrop: (base, group, reason) => { ++ this.spend(base); ++ ++ if (reason === 'expired') { ++ this.log.info('dlrMerger - incomplete receipts expired', { base, expected: group.expected.size }); ++ } else { ++ this.log.warn('dlrMerger - buffer full, dropping the oldest message', { base, max: this.max }); ++ } ++ }, + timeout: options.timeout, + }); + this.log = options.log; +@@ -83,7 +92,7 @@ export class DlrMerger { + this.spent = new ExpiringGroups({ + max: options.max, + now: options.now, +- onSweep: () => { this.spent.takeExpired(); }, ++ onDrop: () => undefined, + timeout: options.timeout, + }); + } +@@ -103,8 +112,6 @@ export class DlrMerger { + + /** The whole message's report, on the receipt that completes it. */ + collect(dlr: Dlr): MessageDlr | undefined { +- this.sweep(); +- + if (dlr.intermediate || dlr.smsId === undefined) return undefined; + + const numbering = parseSegmentId(dlr.smsId); +@@ -126,7 +133,7 @@ export class DlrMerger { + + if (group.parts.size < group.expected.size) return undefined; + +- this.close(base); ++ this.spend(base); + + const segments = [...group.parts.entries()].sort(([a], [b]) => a - b).map(([, one]) => one); + const worst = segments.reduce((carry, one) => (severity[one.statusMsg] > severity[carry.statusMsg] ? one : carry)); +@@ -139,46 +146,20 @@ export class DlrMerger { + this.spent.takeAll(); + } + +- /** Drops every group past its deadline. Runs before each collect and on its own timer. */ +- sweep(): void { +- for (const [base, group] of this.groups.takeExpired()) { +- this.close(base); +- this.log.info('dlrMerger - incomplete receipts expired', { base, expected: group.expected.size }); +- } +- } +- + private open(base: string, expected: Set): void { +- this.spent.takeExpired(); +- + if (this.groups.get(base) !== undefined || this.spent.get(base) === true) { +- this.close(base); ++ this.spend(base); + this.log.info('dlrMerger - message id handed out again, leaving its receipts unmerged', { base }); + + return; + } + +- if (this.groups.full) this.dropOldest(); +- + this.groups.set(base, { expected, parts: new Map() }); + } + +- private close(base: string): void { ++ /** The base is finished with, merged or not, and stays refused for as long as a receipt could still arrive. */ ++ private spend(base: string): void { + this.groups.delete(base); +- this.spent.delete(base); +- +- if (this.spent.full) this.spent.takeOldest(); +- + this.spent.set(base, true); + } +- +- private dropOldest(): void { +- const oldest = this.groups.takeOldest(); +- +- if (!oldest) return; +- +- const [base] = oldest; +- +- this.close(base); +- this.log.warn('dlrMerger - buffer full, dropping the oldest message', { base, max: this.max }); +- } + } +diff --git a/src/expiring-groups.ts b/src/expiring-groups.ts +index 3e276aa..b03f116 100644 +--- a/src/expiring-groups.ts ++++ b/src/expiring-groups.ts +@@ -1,11 +1,12 @@ +-export type ExpiringGroupsOptions = { ++export type DropReason = 'evicted' | 'expired'; ++ ++export type ExpiringGroupsOptions = { + max: number; +- /** Enforced by weigh() alone; set() never evicts. */ + maxWeight?: number | undefined; + /** Injected so expiry can be exercised without a wall clock. */ + now?: (() => number) | undefined; +- /** Must call takeExpired(): the timer itself removes nothing. */ +- onSweep: () => void; ++ /** A group this store let go of on its own: over the count, over the weight, or past its deadline. */ ++ onDrop: (key: string, group: T, reason: DropReason) => void; + timeout: number; + }; + +@@ -15,49 +16,61 @@ type Entry = { + weight: number; + }; + +-/** Enforces neither max nor timeout itself: owners check full and call takeExpired(); only weigh() evicts. */ ++/** A capped, weighed, expiring store: the oldest goes when a cap is hit, and the expired go on every access. */ + export class ExpiringGroups { + private readonly entries = new Map>(); + private readonly max: number; + private readonly maxWeight: number; + private readonly now: () => number; +- private readonly onSweep: () => void; ++ private readonly onDrop: (key: string, group: T, reason: DropReason) => void; + private readonly timeout: number; + private sweeper: NodeJS.Timeout | undefined; + private total = 0; + +- constructor(options: ExpiringGroupsOptions) { ++ constructor(options: ExpiringGroupsOptions) { + this.max = options.max; + this.maxWeight = options.maxWeight ?? Infinity; + this.now = options.now ?? Date.now; +- this.onSweep = options.onSweep; ++ this.onDrop = options.onDrop; + this.timeout = options.timeout; + } + + get full(): boolean { ++ this.expire(); ++ + return this.entries.size >= this.max; + } + + get size(): number { ++ this.expire(); ++ + return this.entries.size; + } + + get weight(): number { ++ this.expire(); ++ + return this.total; + } + + get(key: string): T | undefined { ++ this.expire(); ++ + return this.entries.get(key)?.group; + } + + /** Replacing a key restarts its deadline and zeroes its weight; weigh() it again. */ + set(key: string, group: T): void { ++ this.expire(); + this.remove(key); ++ ++ if (this.entries.size >= this.max) this.dropOldest(); ++ + this.entries.set(key, { deadline: this.now() + this.timeout, group, weight: 0 }); + + if (this.sweeper) return; + +- this.sweeper = setInterval(() => { this.onSweep(); }, this.timeout); ++ this.sweeper = setInterval(() => { this.expire(); }, this.timeout); + this.sweeper.unref(); + } + +@@ -66,27 +79,21 @@ export class ExpiringGroups { + this.idle(); + } + +- /** The returned groups are already removed, and may include key itself. */ +- weigh(key: string, weight: number): [string, T][] { ++ /** The oldest groups go until the total is back under the cap, the weighed one included. */ ++ weigh(key: string, weight: number): void { + const entry = this.entries.get(key); +- const taken: [string, T][] = []; + + if (entry) { + this.total += weight - entry.weight; + entry.weight = weight; + } + +- while (this.total > this.maxWeight) { +- const oldest = this.takeOldest(); +- +- if (!oldest) break; +- +- taken.push(oldest); ++ while (this.total > this.maxWeight && this.entries.size > 0) { ++ this.dropOldest(); + } +- +- return taken; + } + ++ /** Everything, unreported: the owner says what it means for these to go. */ + takeAll(): [string, T][] { + const taken: [string, T][] = []; + +@@ -101,32 +108,28 @@ export class ExpiringGroups { + return taken; + } + +- takeExpired(): [string, T][] { ++ private expire(): void { + const now = this.now(); +- const taken: [string, T][] = []; + + for (const [key, entry] of this.entries) { + if (entry.deadline > now) continue; + +- taken.push([key, entry.group]); + this.remove(key); ++ this.onDrop(key, entry.group, 'expired'); + } + + this.idle(); +- +- return taken; + } + +- takeOldest(): [string, T] | undefined { ++ private dropOldest(): void { + const oldest = this.entries.entries().next(); + +- if (oldest.done) return undefined; ++ if (oldest.done) return; + + const [key, entry] = oldest.value; + +- this.delete(key); +- +- return [key, entry.group]; ++ this.remove(key); ++ this.onDrop(key, entry.group, 'evicted'); + } + + private remove(key: string): void { +diff --git a/src/held-messages.ts b/src/held-messages.ts +deleted file mode 100644 +index b9e740e..0000000 +--- a/src/held-messages.ts ++++ /dev/null +@@ -1,201 +0,0 @@ +-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'; +-import type { SmsHandlers } from './sms.ts'; +-import type { SmppLog } from './log.ts'; +-import { ExpiringGroups } from './expiring-groups.ts'; +-import { IdleWaiters } from './idle-waiters.ts'; +-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; +- sendPastDrain: SmsHandlers['send']; +- session: Session; +- timeout: number; +-}; +- +-/** The peer's own sequence number, which is what our answer to this message will carry. */ +-function keyOf(pduObjs: PduObject[]): string | undefined { +- const first = pduObjs[0]; +- +- return first ? String(first.seqNr) : undefined; +-} +- +-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. +- */ +-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> { +- 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; +- 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(); +- private readonly route: HoldRoute; +- +- constructor(options: HeldMessagesOptions) { +- this.held = new ExpiringGroups({ +- max: options.max, +- now: options.now, +- 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 }; +- } +- +- get octetsHeld(): number { +- return this.held.weight; +- } +- +- get size(): number { +- return this.held.size; +- } +- +- /** Whether a message arriving now is past the bound, once the expired are swept. */ +- 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); +- +- this.sweep(); +- +- if (this.held.get(key)) { +- this.log.warn('heldMessages - replacing a message on a re-used sequence number', { seqNr: Number(key) }); +- } +- +- this.held.set(key, pduObjs); +- this.held.weigh(key, pduObjs.reduce((sum, pduObj) => sum + retainedOctets(pduObj), 0)); +- +- return hold; +- } +- +- offer(pduObjs: PduObject[], answeredAs?: string): MessageHold | 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); +- +- this.offered.set(sms, hold); +- +- if (!this.route.session.emit('sms', sms)) hold.release(); +- +- return hold; +- } +- +- /** 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(); +- } +- +- holds(pduObjs: PduObject[]): boolean { +- const key = keyOf(pduObjs); +- +- return key !== undefined && this.held.get(key) === pduObjs; +- } +- +- release(pduObjs: PduObject[]): void { +- const key = keyOf(pduObjs); +- +- // Identity, not the key: a wrapped sequence number must not release someone else's message. +- if (key === undefined || this.held.get(key) !== pduObjs) return; +- +- this.held.delete(key); +- this.settle(); +- } +- +- /** Drops every message: their segments went with the link, so no answer of ours correlates now. */ +- clear(): void { +- this.held.takeAll(); +- this.idleWaiters.settle(); +- } +- +- /** Resolves 0 once every message has been answered, or with how many have not. */ +- idle(timeout: number, signal: AbortSignal | undefined): Promise { +- 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(); +- +- if (expired.length === 0) return; +- +- this.log.warn('heldMessages - messages the application never answered', { +- messages: expired.length, +- }); +- this.settle(); +- } +- +- private settle(): void { +- if (this.held.size === 0) this.idleWaiters.settle(); +- } +-} +diff --git a/src/incoming-requests.ts b/src/incoming-requests.ts +index 51aeec7..90bca8d 100644 +--- a/src/incoming-requests.ts ++++ b/src/incoming-requests.ts +@@ -1,21 +1,24 @@ ++import type { BindType, LinkEnd, OnRequest, OnSms } from './session-options.ts'; + import type { Concat } from './concat.ts'; +-import type { DlrMerger } from './dlr-merger.ts'; ++import type { Dlr } from './dlr.ts'; + import type { ErrorName } from './defs/errors.ts'; +-import type { HeldMessagesOptions } 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 { ParamValue } from './defs/types.ts'; ++import type { PduObject, PduObjectInput, TlvInputs } from './pdu.ts'; ++import type { Result, VoidResult } from './result.ts'; ++import type { SendRespOptions, Sms } from './sms.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 { RunningHandlers } from './running-handlers.ts'; ++import { bindCommands, standsInFor } from './session-options.ts'; + import { concatOf } from './concat.ts'; +-import { detach } from './retained-pdu.ts'; ++import { createSms } from './sms.ts'; ++import { defaults } from './defaults.ts'; ++import { detach, retainedOctets } from './retained-pdu.ts'; + import { dlrFromPdu } from './dlr.ts'; ++import { errorFrom } from './error-from.ts'; + import { respIdParams, segmentId } from './sms-id.ts'; + import { respNameFor } from './defs/commands.ts'; + +@@ -43,47 +46,60 @@ const lostReasons: Record = { + linkGone: 'the link they arrived on went', + }; + ++/** What the peer's requests need from the session they arrived on. */ ++export type SessionPort = { ++ acceptsOptionalParams: () => boolean; ++ answer: (pduObj: PduObject, status?: ErrorName, params?: Record, tlvs?: TlvInputs) => Promise; ++ bindAllows: (cmdName: string) => boolean; ++ boundAs: () => BindType | undefined; ++ /** Whether the socket has closed, so nothing answered now correlates. */ ++ closed: () => boolean; ++ /** The peer is done with the link. */ ++ end: () => void; ++ linkEnd: LinkEnd; ++ onDlr: (dlr: Dlr, pduObj: PduObject) => void; ++ report: (err: Error) => void; ++ /** A request past the drain's refusal, which a receipt for a message still being handled takes. */ ++ request: (input: PduObjectInput) => Promise>; ++ /** The public handle, for the hooks. */ ++ session: Session; ++}; ++ + export type IncomingRequestsOptions = { +- dlrMerger: DlrMerger; +- link: LinkLife; + log: SmppLog; + maxOctets?: number | undefined; + maxReassembly?: number | undefined; + onRequest?: OnRequest | undefined; ++ onSms?: OnSms | undefined; + reassemblyTimeout?: number | undefined; +- sendPastDrain: HeldMessagesOptions['sendPastDrain']; +- session: Session; + smsIdFormat?: SmsIdFormat | undefined; + systemId?: string | undefined; + }; + ++type Outcome = { answer: SendRespOptions | undefined } | { thrown: Error }; ++ ++/** The handler's return as an answer; a bare return is the answer with nothing named. */ ++function answerOf(returned: Awaited>): SendRespOptions | undefined { ++ return typeof returned === 'object' ? returned : undefined; ++} ++ + /** Everything the peer asks of a session: messages, receipts, links and the answers to them. */ + export class IncomingRequests { +- private readonly dlrMerger: DlrMerger; +- private readonly held: HeldMessages; +- private readonly link: LinkLife; + private readonly log: SmppLog; + private readonly onRequest: OnRequest | undefined; ++ private readonly onSms: OnSms | undefined; ++ private readonly port: SessionPort; + private readonly reassembler: Reassembler; +- private readonly session: Session; ++ private readonly running: RunningHandlers; + 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.link = options.link; ++ constructor(port: SessionPort, options: IncomingRequestsOptions) { + this.log = options.log; + this.onRequest = options.onRequest; ++ this.onSms = options.onSms; ++ this.port = port; + this.reassembler = new Reassembler({ + log: options.log, + max: options.maxReassembly ?? defaults.maxReassembly, +@@ -91,31 +107,34 @@ export class IncomingRequests { + onLost: lost => { this.reportLost(lost); }, + timeout: options.reassemblyTimeout ?? defaults.reassemblyTimeout, + }); +- this.session = options.session; ++ this.running = new RunningHandlers({ ++ log: options.log, ++ max: defaults.maxRunningHandlers, ++ maxOctets: defaults.maxRunningOctets, ++ timeout: defaults.handlerTimeout, ++ }); + this.smsIdFormat = options.smsIdFormat ?? {}; + this.systemId = options.systemId ?? defaults.systemId; + } + + async handle(pduObj: PduObject): Promise { +- const generation = this.link.generation(); + const { onRequest } = this; + + // Called unbound, so the application's hook never sees this class as its `this`. +- if (onRequest && await onRequest(this.session, pduObj)) return; ++ if (onRequest && await onRequest(this.port.session, pduObj)) return; + +- // The link it arrived on went while the hook ran, so nothing we answer now correlates. +- if (this.link.generation() !== generation) { ++ if (this.port.closed()) { + this.log.info('session - dropping a request whose link went', { cmdName: pduObj.cmdName }); + + return; + } + +- if (!this.session.bindAllows(pduObj.cmdName)) { ++ if (!this.port.bindAllows(pduObj.cmdName)) { + this.log.info('session - command the peer\'s bind direction does not carry', { +- bindType: this.session.boundAs ?? '', ++ bindType: this.port.boundAs() ?? '', + cmdName: pduObj.cmdName, + }); +- await this.session.sendReturn(pduObj, 'ESME_RINVBNDSTS'); ++ await this.port.answer(pduObj, 'ESME_RINVBNDSTS'); + + return; + } +@@ -123,57 +142,53 @@ export class IncomingRequests { + await this.route(pduObj); + } + ++ /** Waits out the handlers still running, and says how many never returned. */ ++ async drain(timeout: number, signal: AbortSignal | undefined): Promise { ++ const unanswered = await this.running.idle(timeout, signal); ++ ++ if (unanswered === 0) return {}; ++ ++ this.log.warn('session - shutting down with handlers still running', { timeout, unanswered }); ++ ++ return { err: new Error(`Shut down with ${String(unanswered)} message(s) unanswered`) }; ++ } ++ ++ /** The link is gone: its half-arrived messages are lost, and its handlers answer nothing that correlates. */ ++ end(): void { ++ this.refusing = false; ++ this.running.clear(); ++ this.reassembler.clear(); ++ } ++ + private async route(pduObj: PduObject): Promise { + switch (pduObj.cmdName) { + case 'data_sm': + case 'deliver_sm': + // A data_sm at the SMSC end is a submission, and a submission is never a report. +- await (this.carriedAs(pduObj) === 'submit_sm' ++ await (this.carriedAs(pduObj.cmdName) === 'submit_sm' + ? this.onMessage(pduObj) + : this.onDelivery(pduObj)); + break; + case 'enquire_link': +- await this.session.sendReturn(pduObj); ++ await this.port.answer(pduObj); + break; + case 'submit_sm': + await this.onMessage(pduObj); + break; + case 'unbind': +- await this.session.sendReturn(pduObj); ++ await this.port.answer(pduObj); + // A peer that has said it is finished will not answer what we still have outstanding. +- await this.session.close({ signal: AbortSignal.abort() }); ++ this.port.end(); + break; + default: + await this.unhandled(pduObj); + } + } + +- /** Drops the segments of every message that never became whole, and of every one still held. */ +- 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 { +- 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 { + if (bindCommands.includes(pduObj.cmdName)) { + this.log.info('session - bind on an already bound session', { cmdName: pduObj.cmdName }); +- await this.session.sendReturn(pduObj, 'ESME_RALYBND', { system_id: this.systemId }); ++ await this.port.answer(pduObj, 'ESME_RALYBND', { system_id: this.systemId }); + + return; + } +@@ -185,11 +200,11 @@ export class IncomingRequests { + } + + this.log.info('session - no handler for command', { cmdName: pduObj.cmdName }); +- await this.session.sendReturn(pduObj, 'ESME_RINVCMDID'); ++ await this.port.answer(pduObj, 'ESME_RINVCMDID'); + } + +- private carriedAs(pduObj: PduObject): string { +- return standsInFor(pduObj.cmdName, this.session.linkEnd); ++ private carriedAs(cmdName: string): string { ++ return standsInFor(cmdName, this.port.linkEnd); + } + + /** SMPP carries a mobile-originated message and a delivery receipt on the same command. */ +@@ -202,30 +217,25 @@ export class IncomingRequests { + return; + } + +- this.session.emit('dlr', dlr, pduObj); +- +- const merged = this.dlrMerger.collect(dlr); +- +- if (merged) this.session.emit('messageDlr', merged); +- +- await this.session.sendReturn(pduObj); ++ this.port.onDlr(dlr, pduObj); ++ await this.port.answer(pduObj); + } + + private async refusedAtBound(pduObj: PduObject): Promise { +- if (this.held.full()) { ++ if (this.running.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.warn('session - handlers at their bound, refusing new messages until they return', { ++ messages: this.running.size, ++ octets: this.running.octets, + }); + } + +- this.log.verbose('session - unanswered messages at their bound, asking the peer to retry', { ++ this.log.verbose('session - handlers at their bound, asking the peer to retry', { + cmdName: pduObj.cmdName, + seqNr: pduObj.seqNr, + }); +- await this.session.sendReturn(pduObj, throttledStatus(this.carriedAs(pduObj))); ++ await this.port.answer(pduObj, throttledStatus(this.carriedAs(pduObj.cmdName))); + + return true; + } +@@ -233,11 +243,11 @@ export class IncomingRequests { + // 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.running.size <= defaults.maxRunningHandlers / 2 ++ && this.running.octets <= defaults.maxRunningOctets / 2 + ) { + this.refusing = false; +- this.log.info('session - unanswered messages down to half their bound, accepting again', { messages: this.held.size }); ++ this.log.info('session - handlers down to half their bound, accepting again', { messages: this.running.size }); + } + + return false; +@@ -253,7 +263,7 @@ export class IncomingRequests { + const concat = concatOf(pduObj); + + if (!concat) { +- this.held.offer([detach(pduObj)]); ++ await this.handOver([detach(pduObj)], undefined); + + return; + } +@@ -261,25 +271,87 @@ export class IncomingRequests { + const collected = this.reassembler.collect(pduObj, concat); + + if (!collected.kept) { +- await this.session.sendReturn( ++ await this.port.answer( + pduObj, +- refusedSegmentStatus(this.carriedAs(pduObj), collected.refusal, concat.spelling), ++ refusedSegmentStatus(this.carriedAs(pduObj.cmdName), collected.refusal, concat.spelling), + ); + + return; + } + +- await this.session.sendReturn( ++ await this.port.answer( + pduObj, + 'ESME_ROK', + respIdParams(pduObj.cmdName, segmentId(collected.smsId, concat.part - 1, concat.total)), + ); + +- if (collected.whole) this.held.offer(collected.whole, collected.smsId); ++ if (collected.whole) await this.handOver(collected.whole, collected.smsId); ++ } ++ ++ /** Runs the handler on the message; whatever it leaves unanswered is answered from its outcome. */ ++ private async handOver(pduObjs: PduObject[], answeredAs: string | undefined): Promise { ++ const sms = createSms({ answeredAs, pduObjs, session: this.port.session }, { ++ acceptsOptionalParams: this.port.acceptsOptionalParams, ++ answer: (pduObj, status, params) => this.port.answer(pduObj, status, params), ++ bindAllows: this.port.bindAllows, ++ request: this.port.request, ++ }); ++ const { onSms } = this; ++ ++ // Its segments' answers went with the link, so the peer sends the message again. ++ if (this.port.closed()) return; ++ ++ if (!onSms) { ++ this.log.warn('session - no onSms handler, refusing the message', { seqNr: pduObjs[0]?.seqNr ?? 0 }); ++ await this.refuse(sms, new Error('No onSms handler took the message')); ++ ++ return; ++ } ++ ++ const done = this.running.start(pduObjs.reduce((sum, pduObj) => sum + retainedOctets(pduObj), 0)); ++ const outcome = await this.run(onSms, sms); ++ ++ done(); ++ ++ if ('thrown' in outcome) { ++ this.port.report(outcome.thrown); ++ await this.refuse(sms, outcome.thrown); ++ ++ return; ++ } ++ ++ const answered = await sms.sendResp(outcome.answer ?? {}); ++ ++ if (answered.err) this.port.report(answered.err); ++ } ++ ++ private async run(onSms: OnSms, sms: Sms): Promise { ++ try { ++ return { answer: answerOf(await onSms(sms)) }; ++ } catch (thrown: unknown) { ++ const err = errorFrom(thrown); ++ ++ this.log.error('session - the onSms handler failed', { message: err.message }); ++ ++ return { thrown: err }; ++ } ++ } ++ ++ /** The application did not take the message: the peer keeps it, or it is lost where already answered. */ ++ private async refuse(sms: Sms, reason: Error): Promise { ++ if (sms.answeredOnArrival) { ++ this.port.report(new Error(`Lost a message answered on arrival: ${reason.message}`)); ++ ++ return; ++ } ++ ++ const refused = await sms.sendResp({ status: throttledStatus(this.carriedAs(sms.pduObjs[0]?.cmdName ?? 'submit_sm')) }); ++ ++ if (refused.err) this.port.report(refused.err); + } + + private reportLost(lost: LostGroup): void { +- this.session.emit('sessionError', new Error( ++ this.port.report(new Error( + `Gave up ${String(lost.parts)} of ${String(lost.total)} segments of an incomplete concatenated message: ${lostReasons[lost.reason]}`, + )); + } +diff --git a/src/index.ts b/src/index.ts +index 71fa973..f75773f 100644 +--- a/src/index.ts ++++ b/src/index.ts +@@ -1,4 +1,4 @@ +-export { client } from './client.ts'; ++export { client, SmppClient } from './client.ts'; + export { server, SmppServer } from './server.ts'; + export { Session } from './session.ts'; + +@@ -36,7 +36,7 @@ export { concatInfo } from './udh.ts'; + export { PduFramer } from './pdu-framer.ts'; + export { uuidv7 } from './uuid.ts'; + +-export type { BindType, ClientOptions } from './client.ts'; ++export type { ClientEvents, ClientOptions, ReconnectTuning } from './client.ts'; + export type { Dlr, Receipt } from './dlr.ts'; + export type { SendDlrResult, SendRespOptions, Sms } from './sms.ts'; + export type { Concat } from './concat.ts'; +@@ -50,16 +50,18 @@ export type { + ServerEvents, + ServerOptions, + } from './server.ts'; ++export type { MessageDlr } from './dlr-merger.ts'; ++export type { SendSmsOptions, SendSmsResult } from './send-sms.ts'; + export type { ++ BindType, + CloseOptions, +- MessageDlr, +- ReconnectOptions, ++ LinkEnd, ++ OnRequest, ++ OnSms, + SendOptions, +- SendSmsOptions, +- SendSmsResult, + SessionEvents, + SessionOptions, +-} from './session.ts'; ++} from './session-options.ts'; + export type { CommandName, PduParams, PduParamsInput } from './defs/commands.ts'; + export type { ConstGroup, MessageState, SubmitMessagingMode } from './defs/constants.ts'; + export type { Encoding, EncodingName, Unencodable } from './defs/encodings.ts'; +diff --git a/src/link-life.ts b/src/link-life.ts +deleted file mode 100644 +index f44f2ea..0000000 +--- a/src/link-life.ts ++++ /dev/null +@@ -1,188 +0,0 @@ +-import type { SmppLog } from './log.ts'; +-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(). */ +- 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'; +- +-type Waiter = (result: VoidResult) => void; +- +-function aborted(): Error { +- return new Error('Aborted while waiting for a link'); +-} +- +-function expired(): Error { +- return new Error('The link did not come back in time'); +-} +- +-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. */ +-export class LinkLife { +- private readonly log: SmppLog; +- private readonly now: () => number; +- private readonly reconnects: boolean; +- private readonly timeout: number; +- private readonly waiting = new Set(); +- private drops = 0; +- private phase: Phase = 'up'; +- private stopped = false; +- +- constructor(options: LinkLifeOptions) { +- this.log = options.log; +- this.now = options.now ?? Date.now; +- this.reconnects = options.reconnects; +- this.timeout = options.timeout; +- } +- +- /** A socket is on the link, bound or not. */ +- isAttached(): boolean { +- return this.phase === 'binding' || this.phase === 'up'; +- } +- +- /** Whether a request can go out right now. */ +- isUp(): boolean { +- return this.phase === 'up'; +- } +- +- private isOver(): boolean { +- return this.phase === 'ended'; +- } +- +- /** The session is shutting down: nothing new is taken, and no link follows this one. */ +- isStopped(): boolean { +- return this.stopped; +- } +- +- /** Whether a link that drops now is followed by another. */ +- retrying(): boolean { +- return this.reconnects && !this.stopped; +- } +- +- /** Not up and not over, with a link to come. */ +- awaitsNextLink(): boolean { +- return !this.isUp() && !this.isOver() && this.retrying(); +- } +- +- /** Changes with every drop, so what was read off one link can tell that link is gone. */ +- generation(): number { +- return this.drops; +- } +- +- /** 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(); +- } +- +- /** One budget for a request, however many links it waits through. */ +- hold(signal: AbortSignal | undefined): () => Promise { +- 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'; +- } +- +- /** The link is bound: everything held goes out on it. */ +- open(): void { +- this.phase = 'up'; +- +- if (this.waiting.size > 0) { +- this.log.verbose('linkLife - sending what was held for a link', { held: this.waiting.size }); +- } +- +- this.release({}); +- } +- +- /** 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; +- +- this.phase = 'down'; +- this.drops++; +- +- return this.retrying() ? 'disconnected' : 'close'; +- } +- +- stop(): void { +- this.stopped = true; +- } +- +- /** The session is over: nothing held will ever go out. False means it already was. */ +- end(): boolean { +- if (this.isOver()) return false; +- +- this.phase = 'ended'; +- this.stopped = true; +- this.release({ err: over() }); +- +- return true; +- } +- +- /** Resolves once a link can carry the request, or with the reason none ever will. */ +- private wait(deadline: number, signal: AbortSignal | undefined): Promise { +- if (this.isUp()) return Promise.resolve({}); +- +- const refused = this.refusal(); +- +- if (refused) return Promise.resolve({ err: refused }); +- +- if (signal?.aborted === true) return Promise.resolve({ err: aborted() }); +- +- const left = deadline === 0 ? 0 : deadline - this.now(); +- +- if (deadline !== 0 && left <= 0) return Promise.resolve({ err: expired() }); +- +- return this.waitForLink(left, signal); +- } +- +- private waitForLink(left: number, signal: AbortSignal | undefined): Promise { +- this.log.verbose('linkLife - holding a request until a link is back', { timeout: left }); +- +- return new Promise(resolve => { +- let timer: NodeJS.Timeout | undefined = undefined; +- const settle = (result: VoidResult): void => { +- if (timer) clearTimeout(timer); +- +- signal?.removeEventListener('abort', onAbort); +- this.waiting.delete(settle); +- resolve(result); +- }; +- const giveUp = (): void => { +- this.log.warn('linkLife - no link came back in time', { timeout: left }); +- settle({ err: expired() }); +- }; +- +- function onAbort(): void { +- settle({ err: aborted() }); +- } +- +- // Not unref()'d: a held request is awaited with no other handle, so the process would exit unsettled. +- if (left > 0) timer = setTimeout(giveUp, left); +- +- signal?.addEventListener('abort', onAbort, { once: true }); +- this.waiting.add(settle); +- }); +- } +- +- private release(result: VoidResult): void { +- for (const settle of [...this.waiting]) { +- settle(result); +- } +- } +-} +diff --git a/src/link-lost-error.ts b/src/link-lost-error.ts +new file mode 100644 +index 0000000..485398b +--- /dev/null ++++ b/src/link-lost-error.ts +@@ -0,0 +1,7 @@ ++/** The link went before the request reached the socket, so nothing the peer could have taken. */ ++export class LinkLostError extends Error { ++ constructor() { ++ super('The link went before the request was sent'); ++ this.name = 'LinkLostError'; ++ } ++} +diff --git a/src/outgoing-requests.ts b/src/outgoing-requests.ts +index a0adf24..30e79be 100644 +--- a/src/outgoing-requests.ts ++++ b/src/outgoing-requests.ts +@@ -1,40 +1,27 @@ +-import type { LinkLife } from './link-life.ts'; + import type { PduObject, PduObjectInput } from './pdu.ts'; + import type { PduTransport } from './pdu-transport.ts'; + import type { Result, VoidResult } from './result.ts'; + import type { SendOptions } from './session-options.ts'; + import type { SmppLog } from './log.ts'; ++import { LinkLostError } from './link-lost-error.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 { objToPdu } from './pdu.ts'; + + export type OutgoingRequestsOptions = { +- link: LinkLife; + log: SmppLog; + maxOutstanding: number; + responseTimeout: number; + transport: PduTransport; + }; + +-/** `retryOnNextLink`: the write failed, so nothing reached the socket and another link may carry it. */ +-type Attempt = { result: Result<{ pduObj: PduObject }>; retryOnNextLink: boolean }; +- + function abortedBeforeSend(): Error { + return new Error('Aborted before the request was sent'); + } + +-/** A response carries the request's sequence number, which only sendReturn() has. */ +-function misuse(input: PduObjectInput): Error | undefined { +- return input.cmdName.endsWith('_resp') +- ? new Error(`Use sendReturn() for responses, not send(): ${input.cmdName}`) +- : undefined; +-} +- +-/** Everything this end asks of the peer: which link carries it, how many at once, and the answer. */ ++/** Every request this end asks of the peer: the window it waits in, its sequence number, and the answer. */ + export class OutgoingRequests { +- private readonly link: LinkLife; + private readonly log: SmppLog; + private readonly pending: PendingRequests; + private readonly responseTimeout: number; +@@ -42,7 +29,6 @@ export class OutgoingRequests { + private readonly window: SendWindow; + + constructor(options: OutgoingRequestsOptions) { +- this.link = options.link; + this.log = options.log; + this.pending = new PendingRequests(options.log); + this.responseTimeout = options.responseTimeout; +@@ -50,15 +36,6 @@ export class OutgoingRequests { + this.window = new SendWindow({ limit: options.maxOutstanding, log: options.log }); + } + +- canCarry(): boolean { +- return this.link.isUp() && !this.transport.sock.destroyed; +- } +- +- /** The link is gone, and every answer still owed on it with it. */ +- linkLost(): void { +- this.pending.settleAll(new Error('Session closed before a response arrived')); +- } +- + /** Hands a response to the request waiting for it. False means nothing was. */ + deliver(pduObj: PduObject): boolean { + return this.pending.deliver(pduObj); +@@ -69,59 +46,20 @@ export class OutgoingRequests { + this.pending.settle(seqNr, { err }); + } + +- request(input: PduObjectInput, options: SendOptions): Promise> { +- // Ahead of the drain, so a misuse is named as one rather than blamed on the shutdown. +- const wrong = misuse(input); +- +- if (wrong) return Promise.resolve({ err: wrong }); +- +- // With no link, the request is refused as closed further on. +- if (this.link.isStopped() && 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> { +- const refused = this.refuse(input, options); ++ /** Waits for a window slot, writes, and resolves with the answer. A `LinkLostError` means nothing was written. */ ++ async request(input: PduObjectInput, options: SendOptions): Promise> { ++ // Before the window, or an aborted call queues for a slot it will never use. ++ if (options.signal?.aborted === true) return { err: abortedBeforeSend() }; + +- if (refused) return { err: refused }; ++ const slot = await this.window.acquire(options.signal); + +- // 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(); ++ if (slot.err) return { err: slot.err }; + +- return shut ? { err: shut } : this.requestOnCurrentLink(input, options); ++ try { ++ return await this.attempt(input, options); ++ } finally { ++ this.window.release(); + } +- +- 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; +- } +- } +- +- /** Straight onto the current link, for what has to go out either way. */ +- async requestOnCurrentLink( +- input: PduObjectInput, +- options: SendOptions = {}, +- ): Promise> { +- return (await this.attempt(input, options)).result; + } + + /** Waits out the requests already on the wire, and says how many never finished. */ +@@ -135,44 +73,33 @@ export class OutgoingRequests { + return { err: new Error(`Shut down with ${String(unfinished)} request(s) unfinished`) }; + } + +- /** 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(); +- } +- +- /** Why a request cannot go out at all, as opposed to not yet. */ +- private refuse(input: PduObjectInput, options: SendOptions): Error | undefined { +- // Before the link and the window, or an aborted call waits for what it will never use. +- return misuse(input) ?? (options.signal?.aborted === true ? abortedBeforeSend() : undefined); ++ /** The link is gone: what went out is unanswered, and what was still queued never left. */ ++ end(): void { ++ this.pending.settleAll(new Error('Session closed before a response arrived')); ++ this.window.close(new LinkLostError()); + } + +- private async attempt(input: PduObjectInput, options: SendOptions): Promise { ++ private async attempt(input: PduObjectInput, options: SendOptions): Promise> { + // pending.wait() alone settles the caller while the request still goes out to the peer. +- if (options.signal?.aborted === true) { +- return { result: { err: abortedBeforeSend() }, retryOnNextLink: false }; +- } ++ if (options.signal?.aborted === true) return { err: abortedBeforeSend() }; + + const seqNr = this.pending.nextSeqNr(); + const built = objToPdu({ ...input, seqNr }); + +- if (built.err) return { result: { err: built.err }, retryOnNextLink: false }; ++ if (built.err) return { err: built.err }; + +- const response = this.pending.wait(seqNr, { +- signal: options.signal, +- timeout: this.responseTimeout, +- }); ++ const response = this.pending.wait(seqNr, { signal: options.signal, timeout: this.responseTimeout }); + const written = this.transport.write(built.buffer); + + if (written.err) { + this.pending.settle(seqNr, { err: written.err }); + +- return { result: { err: written.err }, retryOnNextLink: true }; ++ return { err: new LinkLostError() }; + } + + const answered = await response; + + // It went out, so a failure now means the peer may have taken it and the answer was the loss. +- return { result: answered.err ? { err: new UnansweredError(answered.err) } : answered, retryOnNextLink: false }; ++ return answered.err ? { err: new UnansweredError(answered.err) } : answered; + } + } +diff --git a/src/pdu-transport.ts b/src/pdu-transport.ts +index 966cdee..730d880 100644 +--- a/src/pdu-transport.ts ++++ b/src/pdu-transport.ts +@@ -21,45 +21,27 @@ export type PduTransportOptions = { + onUnreadable: (err: Error) => void; + }; + +-/** A socket read as a stream of complete PDUs. A reconnect attaches a new socket in its place. */ ++/** One socket read as a stream of complete PDUs. */ + export class PduTransport { ++ readonly sock: Socket; ++ private readonly framer = new PduFramer(); + private readonly options: PduTransportOptions; +- private framer = new PduFramer(); +- private socket: Socket; + + constructor(options: PduTransportOptions, sock: Socket) { + this.options = options; +- this.socket = sock; +- this.wire(sock); +- } +- +- get sock(): Socket { +- return this.socket; +- } +- +- /** Takes over a freshly opened socket. Half a PDU left on the old one must not prefix this one. */ +- attach(sock: Socket): void { +- // The socket being replaced is already dead, and its three handlers still point here. +- this.socket.removeAllListeners(); +- this.socket = sock; +- this.framer = new PduFramer(); +- this.wire(sock); +- } +- +- private wire(sock: Socket): void { ++ this.sock = sock; + sock.on('data', chunk => { this.read(chunk); }); + sock.on('close', () => { this.options.onClose(); }); + sock.on('error', err => { + this.options.log.warn('transport - socket error', { message: err.message }); + this.options.onError(err); +- this.options.onClose(); + }); + } + + write(pdu: Buffer): VoidResult { +- if (this.socket.destroyed) return { err: new Error('Socket is closed') }; ++ if (this.sock.destroyed) return { err: new Error('Socket is closed') }; + +- this.socket.write(pdu); ++ this.sock.write(pdu); + + return {}; + } +diff --git a/src/reassembly.ts b/src/reassembly.ts +index 4f3d2c5..438d86f 100644 +--- a/src/reassembly.ts ++++ b/src/reassembly.ts +@@ -1,8 +1,10 @@ + import type { Concat } from './concat.ts'; ++import type { DropReason } from './expiring-groups.ts'; + import type { PduObject } from './pdu.ts'; + import type { SmppLog } from './log.ts'; + import { ExpiringGroups } from './expiring-groups.ts'; + import { decodeMessage } from './message.ts'; ++import { defaults } from './defaults.ts'; + import { detach, retainedOctets } from './retained-pdu.ts'; + import { messageOctets } from './message-body.ts'; + import { paramNumber, paramText } from './defs/types.ts'; +@@ -11,7 +13,7 @@ import { uuidv7 } from './uuid.ts'; + /** A concatenated message given up on, whose segments the peer has already been answered for. */ + export type LostGroup = { + parts: number; +- reason: 'evicted' | 'expired' | 'linkGone'; ++ reason: DropReason | 'linkGone'; + smsId: string; + total: number; + }; +@@ -42,8 +44,6 @@ export type Collected = + whole?: PduObject[] | undefined; + }; + +-export const defaultMaxOctets = 64 * 1024 * 1024; +- + type Group = { + parts: Map; + smsId: string; +@@ -87,14 +87,16 @@ export class Reassembler { + private readonly maxOctets: number; + private readonly newId: () => string; + private readonly onLost: (lost: LostGroup) => void; ++ /** The group being weighed: its newest segment stays with the peer if the group goes, so it is none of the loss. */ ++ private weighing: string | undefined; + + constructor(options: ReassemblerOptions) { +- this.maxOctets = options.maxOctets ?? defaultMaxOctets; ++ this.maxOctets = options.maxOctets ?? defaults.maxReassemblyOctets; + this.groups = new ExpiringGroups({ + max: options.max, + maxWeight: this.maxOctets, + now: options.now, +- onSweep: () => { this.sweep(); }, ++ onDrop: (key, group, reason) => { this.dropped(key, group, reason); }, + timeout: options.timeout, + }); + this.log = options.log; +@@ -109,8 +111,6 @@ export class Reassembler { + + /** The group the segment joined, and always an answer for it: an unanswered one stalls a peer. */ + collect(pduObj: PduObject, concat: Concat): Collected { +- this.sweep(); +- + const key = groupKey(pduObj, concat); + const existing = this.groups.get(key); + +@@ -122,7 +122,7 @@ export class Reassembler { + + if (group.parts.size < group.total) { + // Its own arrival overran the octet cap, so the peer keeps it rather than being told we did. +- if (!this.trim(key, group)) return { kept: false, refusal: 'full' }; ++ if (!this.weighed(key, group)) return { kept: false, refusal: 'full' }; + + return { kept: true, smsId: group.smsId }; + } +@@ -136,19 +136,13 @@ export class Reassembler { + }; + } + ++ /** The link is gone: every group on it is lost, and reported so. */ + clear(): void { + for (const [, group] of this.groups.takeAll()) { + this.lost(group, 'linkGone'); + } + } + +- /** Drops every group past its deadline. Runs before each collect and on its own timer. */ +- sweep(): void { +- for (const [, group] of this.groups.takeExpired()) { +- this.lost(group, 'expired'); +- } +- } +- + /** Whether a segment can join a group at all: its own numbering, and the group's total. */ + private placeable(concat: Concat, existing: Group | undefined): boolean { + if (concat.part < 1 || concat.total < 1 || concat.part > concat.total) { +@@ -175,8 +169,6 @@ export class Reassembler { + } + + private open(key: string, total: number): Group { +- if (this.groups.full) this.dropOldest(); +- + const group: Group = { parts: new Map(), smsId: this.newId(), total }; + + this.groups.set(key, group); +@@ -184,32 +176,25 @@ export class Reassembler { + return group; + } + +- /** Drops the oldest groups until the retained payload is back under the octet cap. False if the current one went. */ +- private trim(current: string, group: Group): boolean { ++ /** Weighs the group in; false if that put it over the cap and it went. */ ++ private weighed(key: string, group: Group): boolean { + let octets = 0; + + for (const part of group.parts.values()) { + octets += retainedOctets(part); + } + +- let survived = true; +- +- for (const [key, oldest] of this.groups.weigh(current, octets)) { +- if (key === current) survived = false; +- +- // The refused segment is in the group but stays with the peer, so it is none of the loss. +- const answered = key === current ? oldest.parts.size - 1 : oldest.parts.size; +- +- if (answered > 0) this.lost(oldest, 'evicted', answered); +- } ++ this.weighing = key; ++ this.groups.weigh(key, octets); ++ this.weighing = undefined; + +- return survived; ++ return this.groups.get(key) !== undefined; + } + +- private dropOldest(): void { +- const oldest = this.groups.takeOldest(); ++ private dropped(key: string, group: Group, reason: DropReason): void { ++ const parts = key === this.weighing ? group.parts.size - 1 : group.parts.size; + +- if (oldest) this.lost(oldest[1], 'evicted'); ++ if (parts > 0) this.lost(group, reason, parts); + } + + /** Its segments are answered, so the peer will not send them again: this is traffic gone. */ +diff --git a/src/reconnect-loop.ts b/src/reconnect-loop.ts +index af8044d..fc89d2b 100644 +--- a/src/reconnect-loop.ts ++++ b/src/reconnect-loop.ts +@@ -1,51 +1,87 @@ +-import type { Result, VoidResult } from './result.ts'; ++import type { Result } from './result.ts'; ++import type { Session } from './session.ts'; + import type { SmppLog } from './log.ts'; +-import type { Socket } from 'node:net'; +- +-export const backoffDefaults = { +- maxDelay: 30_000, +- minDelay: 1000, +-}; ++import { defaults } from './defaults.ts'; + + export type ReconnectLoopOptions = { +- connect: () => Promise>; + log: SmppLog; + maxDelay?: number | undefined; + minDelay?: number | undefined; + now?: (() => number) | undefined; +- /** Brings the owner back up on a freshly opened socket. An err means try again. */ +- onConnected: (sock: Socket) => Promise; +- /** Whether the wait between attempts lets the process exit. Default true. */ +- unref?: boolean | undefined; ++ /** The current link went. `retrying` says whether another is on its way. */ ++ onDown: (retrying: boolean) => void; ++ /** A link came up bound; `first` is false for every one after the first. */ ++ onUp: (session: Session, first: boolean) => void; ++ /** Opens a socket and binds a session on it. An err means try again after the backoff. */ ++ open: () => Promise>; + }; + +-/** Reopens a dropped connection, backing off between attempts until it is told to stop. */ ++type Waiter = (result: Result<{ session: Session }>) => void; ++ ++function aborted(): Error { ++ return new Error('Aborted while waiting for a link'); ++} ++ ++function expired(): Error { ++ return new Error('The link did not come back in time'); ++} ++ ++function over(): Error { ++ return new Error('Session is closed'); ++} ++ ++/** One bound session at a time, opened again after each one ends, backing off until told to stop. */ + export class ReconnectLoop { ++ private readonly log: SmppLog; + private readonly maxDelay: number; + private readonly minDelay: number; + private readonly now: () => number; + private readonly options: ReconnectLoopOptions; ++ private readonly waiting = new Set(); + private attempting = false; + private delay: number; +- private halted = false; ++ private links = 0; ++ private session: Session | undefined; ++ private stopped = false; + private timer: NodeJS.Timeout | undefined; + private upAt: number | undefined; + + constructor(options: ReconnectLoopOptions) { +- this.maxDelay = options.maxDelay ?? backoffDefaults.maxDelay; +- this.minDelay = options.minDelay ?? backoffDefaults.minDelay; ++ this.log = options.log; ++ this.maxDelay = options.maxDelay ?? defaults.maxDelay; ++ this.minDelay = options.minDelay ?? defaults.minDelay; + this.now = options.now ?? Date.now; + this.options = options; + this.delay = this.minDelay; + } + +- /** Read through a method: stop() can land while an attempt is awaiting. */ +- private isStopped(): boolean { +- return this.halted; ++ /** The bound session, while one is up. */ ++ current(): Session | undefined { ++ return this.session; ++ } ++ ++ /** Takes a session that was opened elsewhere as the current link. */ ++ adopt(session: Session): void { ++ this.session = session; ++ this.upAt = this.now(); ++ this.links++; ++ session.on('close', () => { this.down(session); }); ++ this.options.onUp(session, this.links === 1); ++ this.release({ session }); ++ } ++ ++ /** Resolves with the bound session now or the next one to come up; a timeout of 0 waits for as long as one may. */ ++ bound(timeout: number, signal: AbortSignal | undefined): Promise> { ++ if (this.session) return Promise.resolve({ session: this.session }); ++ if (this.stopped) return Promise.resolve({ err: over() }); ++ if (signal?.aborted === true) return Promise.resolve({ err: aborted() }); ++ ++ return this.waitForLink(timeout, signal); + } + ++ /** Arms the next attempt, at the current backoff. */ + schedule(): void { +- if (this.timer || this.attempting || this.isStopped()) return; ++ if (this.timer || this.attempting || this.stopped) return; + + // Coming up is not proof: a stream we cannot read is only found once the link is bound. + if (this.upAt !== undefined && this.now() - this.upAt >= this.maxDelay) { +@@ -59,85 +95,96 @@ export class ReconnectLoop { + // Announced when the wait is over rather than when it starts: a cancelled one never happened. + this.timer = setTimeout(() => { + this.timer = undefined; +- this.options.log.info('reconnect - retrying', { delay }); +- void this.run(); ++ this.log.info('reconnect - retrying', { delay }); ++ void this.attempt(); + }, delay); + +- if (this.options.unref ?? true) this.timer.unref(); ++ // Before the first link the wait is all the process has; after one, it must not hold the process. ++ if (this.links > 0) this.timer.unref(); + + this.delay = Math.min(delay * 2, this.maxDelay); + } + ++ /** No further attempts; the current session, if any, is the caller's to close. */ + stop(): void { +- this.halted = true; ++ this.stopped = true; + + if (this.timer) clearTimeout(this.timer); + + this.timer = undefined; ++ this.release({ err: over() }); + } + +- private async run(): Promise { +- this.attempting = true; +- +- // connect() and onConnected() are the application's, so a throw from either lands here. +- const retry = await this.attempt().catch((thrown: unknown) => { +- const err = thrown instanceof Error ? thrown : new Error(String(thrown)); +- +- this.options.log.error('reconnect - an attempt threw', { message: err.message }); +- +- return true; +- }); ++ private down(session: Session): void { ++ if (this.session !== session) return; + +- this.attempting = false; ++ this.session = undefined; ++ this.options.onDown(!this.stopped); + +- if (retry) this.schedule(); ++ if (!this.stopped) this.schedule(); + } + +- /** True means the attempt failed and the loop should try again. */ +- private async attempt(): Promise { +- if (this.isStopped()) return false; ++ private async attempt(): Promise { ++ this.attempting = true; ++ ++ // open() runs the application's connect and bind, so a throw from either lands here. ++ const opened = await Promise.resolve() ++ .then(() => this.options.open()) ++ .catch((thrown: unknown): Result<{ session: Session }> => ({ ++ err: thrown instanceof Error ? thrown : new Error(String(thrown)), ++ })); + +- const opened = await this.options.connect(); ++ this.attempting = false; + + if (opened.err) { +- this.options.log.warn('reconnect - could not open a socket', { +- message: opened.err.message, +- }); ++ this.log.warn('reconnect - could not come back up', { message: opened.err.message }); ++ this.schedule(); + +- return true; ++ return; + } + +- if (this.isStopped()) { +- opened.sock.destroy(); ++ if (this.stopped) { ++ void opened.session.close({ signal: AbortSignal.abort() }); + +- return false; ++ return; + } + +- const up = await this.bringUp(opened.sock); +- +- if (up.err) { +- this.options.log.warn('reconnect - could not come back up', { message: up.err.message }); ++ this.adopt(opened.session); ++ } + +- return true; +- } ++ private waitForLink(timeout: number, signal: AbortSignal | undefined): Promise> { ++ this.log.verbose('reconnect - holding a request until a link is back', { timeout }); + +- this.upAt = this.now(); ++ return new Promise>(resolve => { ++ let timer: NodeJS.Timeout | undefined = undefined; ++ const settle: Waiter = result => { ++ if (timer) clearTimeout(timer); + +- return false; +- } ++ signal?.removeEventListener('abort', onAbort); ++ this.waiting.delete(settle); ++ resolve(result); ++ }; ++ const giveUp = (): void => { ++ this.log.warn('reconnect - no link came back in time', { timeout }); ++ settle({ err: expired() }); ++ }; + +- /** The loop owns the socket until the owner is up on it, so a failed handover must not leak it. */ +- private async bringUp(sock: Socket): Promise { +- try { +- const up = await this.options.onConnected(sock); ++ function onAbort(): void { ++ settle({ err: aborted() }); ++ } + +- if (up.err) sock.destroy(); ++ // Not unref()'d: a held request is awaited with no other handle, so the process would exit unsettled. ++ if (timeout > 0) timer = setTimeout(giveUp, timeout); + +- return up; +- } catch (thrown: unknown) { +- sock.destroy(); ++ signal?.addEventListener('abort', onAbort, { once: true }); ++ this.waiting.add(settle); ++ }); ++ } + +- return { err: thrown instanceof Error ? thrown : new Error(String(thrown)) }; ++ private release(result: Result<{ session: Session }>): void { ++ for (const settle of [...this.waiting]) { ++ settle(result); + } + } + } ++ +diff --git a/src/running-handlers.ts b/src/running-handlers.ts +new file mode 100644 +index 0000000..18bade2 +--- /dev/null ++++ b/src/running-handlers.ts +@@ -0,0 +1,76 @@ ++import type { SmppLog } from './log.ts'; ++import { ExpiringGroups } from './expiring-groups.ts'; ++import { IdleWaiters } from './idle-waiters.ts'; ++ ++export type RunningHandlersOptions = { ++ log: SmppLog; ++ max: number; ++ maxOctets: number; ++ /** Injected so expiry can be exercised without a wall clock. */ ++ now?: (() => number) | undefined; ++ timeout: number; ++}; ++ ++/** The onSms handlers still running: counted against a bound, waited on by a drain, forgotten past a timeout. */ ++export class RunningHandlers { ++ private readonly idleWaiters = new IdleWaiters(); ++ private readonly log: SmppLog; ++ private readonly maxOctets: number; ++ private readonly running: ExpiringGroups; ++ private serial = 0; ++ ++ constructor(options: RunningHandlersOptions) { ++ this.log = options.log; ++ this.maxOctets = options.maxOctets; ++ this.running = new ExpiringGroups({ ++ max: options.max, ++ now: options.now, ++ onDrop: () => { ++ this.log.warn('runningHandlers - giving up on a handler that never returned', { timeout: options.timeout }); ++ this.settle(); ++ }, ++ timeout: options.timeout, ++ }); ++ } ++ ++ get octets(): number { ++ return this.running.weight; ++ } ++ ++ get size(): number { ++ return this.running.size; ++ } ++ ++ /** Whether a message arriving now is past the bound. */ ++ full(): boolean { ++ return this.running.full || this.running.weight >= this.maxOctets; ++ } ++ ++ /** Counts a handler from now until the returned function is called. */ ++ start(octets: number): () => void { ++ const key = String(this.serial++); ++ ++ this.running.set(key, true); ++ this.running.weigh(key, octets); ++ ++ return () => { ++ this.running.delete(key); ++ this.settle(); ++ }; ++ } ++ ++ /** Resolves 0 once no handler is running, or with how many still are. */ ++ idle(timeout: number, signal: AbortSignal | undefined): Promise { ++ return this.idleWaiters.wait(() => this.running.size, timeout, signal); ++ } ++ ++ /** Forgets every handler: the link is gone, so nothing they answer correlates now. */ ++ clear(): void { ++ this.running.takeAll(); ++ this.idleWaiters.settle(); ++ } ++ ++ private settle(): void { ++ if (this.running.size === 0) this.idleWaiters.settle(); ++ } ++} +diff --git a/src/send-window.ts b/src/send-window.ts +index e13d67d..36c1cbb 100644 +--- a/src/send-window.ts ++++ b/src/send-window.ts +@@ -56,6 +56,13 @@ export class SendWindow { + this.idleWaiters.settle(); + } + ++ /** Settles everything still queued with the reason no slot will ever come. */ ++ close(err: Error): void { ++ for (const settle of [...this.waiting]) { ++ settle({ err }); ++ } ++ } ++ + /** Everything the caller is still owed: on the wire, plus queued behind a full window. */ + unfinished(): number { + return this.inFlight + this.waiting.size; +diff --git a/src/server.ts b/src/server.ts +index 2b9aefe..c90d9ca 100644 +--- a/src/server.ts ++++ b/src/server.ts +@@ -1,15 +1,15 @@ +-import type { BindType, CloseOptions, OnRequest } from './session-options.ts'; ++import type { BindType, CloseOptions, OnRequest, OnSms } 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'; + 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 { Session } from './session.ts'; + import { bindTypeFromCommand, 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'; ++import { defaults } from './defaults.ts'; + import { errorFrom } from './error-from.ts'; + import { paramText } from './defs/types.ts'; + import { guardedLog } from './log.ts'; +@@ -35,6 +35,8 @@ export type ServerOptions = { + maxReassembly?: number; + /** First refusal on every request a bound peer sends. */ + onRequest?: OnRequest; ++ /** Every message a bound peer submits, on any session; `sms.session` says which. */ ++ onSms?: OnSms; + port?: number; + reassemblyTimeout?: number; + responseTimeout?: number; +@@ -49,13 +51,6 @@ export type ServerEvents = { + session: [Session]; + }; + +-const defaults = { +- idleTimeout: 40_000, +- interfaceVersion: defaultInterfaceVersion, +- port: 2775, +- systemId: defaultSystemId, +-}; +- + /** A listener may return a promise: an `async` one that rejects is routed like one that throws. */ + type ServerListener = (...args: ServerEvents[K]) => unknown; + +@@ -233,12 +228,14 @@ async function handleRequest( + function onConnection(sock: Socket, options: ServerOptions, server: SmppServer): void { + const log = guardedLog(options.log); + const session = new Session({ +- idleTimeout: options.idleTimeout ?? defaults.idleTimeout, ++ idleTimeout: options.idleTimeout ?? defaults.serverIdleTimeout, ++ linkEnd: 'smsc', + log, + maxOutstanding: options.maxOutstanding, + maxOctets: options.maxOctets, + maxReassembly: options.maxReassembly, + onRequest: (bound, pduObj) => handleRequest(bound, pduObj, options), ++ onSms: options.onSms, + reassemblyTimeout: options.reassemblyTimeout, + responseTimeout: options.responseTimeout, + shutdownTimeout: options.shutdownTimeout, +@@ -246,7 +243,6 @@ function onConnection(sock: Socket, options: ServerOptions, server: SmppServer): + systemId: options.systemId ?? defaults.systemId, + }); + +- session.linkEnd = 'smsc'; + server.sessions.add(session); + session.on('close', () => server.sessions.delete(session)); + +@@ -269,28 +265,11 @@ function createSecureListener(tlsOptions: TlsOptions, log: SmppLog): TlsServer { + return listener; + } + +-/** A caller without types would otherwise reach a TypeError once per PDU rather than once here. */ +-function checkHooks(options: ServerOptions): VoidResult { +- for (const name of ['authenticate', 'onRequest'] as const) { +- const hook: unknown = options[name]; +- +- if (hook !== undefined && typeof hook !== 'function') { +- return { err: new Error(`${name} must be a function, got ${typeof hook}`) }; +- } +- } +- +- return {}; +-} +- + function checkOptions(options: ServerOptions, log: SmppLog, port: number): VoidResult { + const checked = checkSessionOptions(options); + + if (checked.err) return { err: checked.err }; + +- const hooks = checkHooks(options); +- +- if (hooks.err) return hooks; +- + if (options.tls === true) { + log.warn('server - tls without a certificate', { port }); + +diff --git a/src/session-options.ts b/src/session-options.ts +index 0b768c9..86ffab7 100644 +--- a/src/session-options.ts ++++ b/src/session-options.ts +@@ -1,29 +1,23 @@ + import type { Dlr } from './dlr.ts'; +-import type { MessageDlr } from './dlr-merger.ts'; + import type { PduObject } from './pdu.ts'; + import type { PduRefusedError } from './pdu-refusal.ts'; + import type { Result, VoidResult } from './result.ts'; ++import type { SendRespOptions, Sms } from './sms.ts'; + import type { Session } from './session.ts'; + import type { SmppLog } from './log.ts'; + import type { SmsIdFormat } from './sms-id.ts'; +-import type { Sms } from './sms.ts'; + import type { Socket } from 'node:net'; +-import { backoffDefaults } from './reconnect-loop.ts'; +-import { defaultMaxOctets } from './reassembly.ts'; ++import { defaults } from './defaults.ts'; + import { isSmsIdNotation, smsIdNotations, smsIdPlaces } from './sms-id.ts'; + import { namedValue } from './error-from.ts'; + + export type SessionEvents = { + close: []; + data: [Buffer]; +- disconnected: []; + dlr: [Dlr, PduObject]; + incomingPdu: [Buffer]; + incomingPduObj: [PduObject]; +- messageDlr: [MessageDlr]; +- reconnected: []; + sessionError: [Error | PduRefusedError]; +- sms: [Sms]; + }; + + export const bindCommands: readonly string[] = [ +@@ -86,28 +80,27 @@ export type CloseOptions = { signal?: AbortSignal | undefined }; + export type OnRequest = (session: Session, pduObj: PduObject) => Promise | boolean; + + /** +- * How to come back after an unexpected disconnect. The session owns the retry loop; the caller +- * supplies how to open a socket and what to do once it is open (bind, for a client). ++ * Every inbound message. Returning answers it: nothing for `ESME_ROK` under `sms.smsId`, or what ++ * `sendResp()` takes. Throwing or rejecting refuses it with the status that has the peer retry. + */ +-export type ReconnectOptions = { +- connect: () => Promise>; +- maxDelay?: number | undefined; +- minDelay?: number | undefined; +- onConnected: (session: Session) => Promise; +-}; ++// A handler that returns nothing is the common case, and void is what such an arrow infers. ++// eslint-disable-next-line @typescript-eslint/no-invalid-void-type ++export type OnSms = (sms: Sms) => Promise | SendRespOptions | void; + + export type SessionOptions = { + enquireLinkInterval?: number | undefined; + idleTimeout?: number | undefined; ++ /** Which end of the link this is; `server()` says `smsc`. Default `esme`. */ ++ linkEnd?: LinkEnd | undefined; + log?: SmppLog | undefined; + maxOctets?: number | undefined; + maxOutstanding?: number | undefined; + maxReassembly?: number | undefined; + onRequest?: OnRequest | undefined; ++ onSms?: OnSms | undefined; + reassemblyTimeout?: number | undefined; +- reconnect?: ReconnectOptions | undefined; + responseTimeout?: number | undefined; +- /** How long a drain waits for the requests already on the wire. 0 waits forever. */ ++ /** How long a drain waits for the handlers still running and the requests on the wire. 0 waits forever. */ + shutdownTimeout?: number | undefined; + /** The notation the peer writes message ids in, where it is not the one they are compared in. */ + smsIdFormat?: SmsIdFormat | undefined; +@@ -116,8 +109,6 @@ export type SessionOptions = { + systemId?: string | undefined; + }; + +-export const defaultSystemId = ''; +- + /** SMPP 3.4: a peer that declares no version at all is one from before optional parameters. */ + export const undeclaredInterfaceVersion = 0x00; + +@@ -146,22 +137,6 @@ export function checkedBind(bindType: unknown, declaredVersion: unknown): Result + 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, +- /** The peer gave up on an unanswered message long before this; the bound is against growth. */ +- heldMessageTimeout: 300_000, +- maxDlrMerges: 1000, +- maxHeldMessages: 1000, +- maxHeldOctets: 64 * 1024 * 1024, +- maxOutstanding: 10, +- maxReassembly: 1000, +- reassemblyTimeout: 300_000, +- responseTimeout: 30_000, +- shutdownTimeout: 5000, +- systemId: defaultSystemId, +-}; +- + /** + * A count below 1 does not fail loudly anywhere downstream: `maxOutstanding: 0` leaves every send + * queued behind a slot that is never freed, so a send with no `signal` never settles at all. +@@ -171,6 +146,10 @@ export function checkSessionOptions(options: CheckableOptions): VoidResult { + return { err: new Error('fromStart is part of the reconnect policy, spell it reconnect: { fromStart: true }') }; + } + ++ const hooks = checkHooks(options); ++ ++ if (hooks.err) return hooks; ++ + const connect = checkConnectTimeout(options.connectTimeout); + + if (connect.err) return connect; +@@ -184,10 +163,23 @@ export function checkSessionOptions(options: CheckableOptions): VoidResult { + return backoff.err ? backoff : checkSmsIdFormat(options.smsIdFormat); + } + ++/** A caller without types would otherwise reach a TypeError once per PDU rather than once here. */ ++function checkHooks(options: CheckableOptions): VoidResult { ++ for (const name of ['authenticate', 'onRequest', 'onSms'] as const) { ++ const hook = options[name]; ++ ++ if (hook !== undefined && typeof hook !== 'function') { ++ return { err: new Error(`${name} must be a function, got ${typeof hook}`) }; ++ } ++ } ++ ++ return {}; ++} ++ + function limitsOf(options: CheckableOptions): [string, number, number][] { + return [ + ['idleTimeout', options.idleTimeout ?? 0, 0], +- ['maxOctets', options.maxOctets ?? defaultMaxOctets, 1], ++ ['maxOctets', options.maxOctets ?? defaults.maxReassemblyOctets, 1], + ['maxOutstanding', options.maxOutstanding ?? defaults.maxOutstanding, 1], + ['maxReassembly', options.maxReassembly ?? defaults.maxReassembly, 1], + ['reassemblyTimeout', options.reassemblyTimeout ?? defaults.reassemblyTimeout, 0], +@@ -243,8 +235,8 @@ function checkReconnect(reconnect: unknown): VoidResult { + return { err: new Error(`reconnect.fromStart must be true or false, got ${typeof reconnect.fromStart}`) }; + } + +- const maxDelay = delayOr(reconnect.maxDelay, backoffDefaults.maxDelay); +- const minDelay = delayOr(reconnect.minDelay, backoffDefaults.minDelay); ++ const maxDelay = delayOr(reconnect.maxDelay, defaults.maxDelay); ++ const minDelay = delayOr(reconnect.minDelay, defaults.minDelay); + // A delay of 0 never doubles, so the backoff never starts and every retry lands at once. + const checked = checkLimits([['maxDelay', maxDelay, 1], ['minDelay', minDelay, 1]]); + +@@ -292,6 +284,7 @@ function checkSmsIdFormat(smsIdFormat: unknown): VoidResult { + + /** What the checker reads, as it arrives: a caller without types can put anything in it. */ + export type CheckableOptions = { ++ authenticate?: unknown; + connectTimeout?: unknown; + /** Not an option: the one spelling is inside reconnect, and this is where the other is refused. */ + fromStart?: unknown; +@@ -299,6 +292,8 @@ export type CheckableOptions = { + maxOctets?: number | undefined; + maxOutstanding?: number | undefined; + maxReassembly?: number | undefined; ++ onRequest?: unknown; ++ onSms?: unknown; + reassemblyTimeout?: number | undefined; + reconnect?: unknown; + responseTimeout?: number | undefined; +diff --git a/src/session.ts b/src/session.ts +index 1fd9b46..2e74da3 100644 +--- a/src/session.ts ++++ b/src/session.ts +@@ -1,47 +1,44 @@ + import type { ErrorName } from './defs/errors.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 { BindType, CloseOptions, LinkEnd, SendOptions, SessionBind, SessionEvents, SessionOptions } from './session-options.ts'; + import type { Result, VoidResult } from './result.ts'; + import type { SendSmsOptions, SendSmsResult } from './send-sms.ts'; ++import type { SessionPort } from './incoming-requests.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 { 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 { UnansweredError } from './unanswered-error.ts'; + import { leftOf } from './idle-waiters.ts'; + import { errorFrom } from './error-from.ts'; + import { optionalParamsMinVersion } from './defs/constants.ts'; +-import { bindCarries, bindCommands, checkedBind, defaultSystemId, defaults } from './session-options.ts'; ++import { bindCarries, checkedBind } from './session-options.ts'; ++import { defaults } from './defaults.ts'; + import { isResp, objToPdu, pduReturn } from './pdu.ts'; + import { refusalAnswer } from './pdu-refusal.ts'; + import { guardedLog } from './log.ts'; + import { submitSms, unsent } from './send-sms.ts'; + import { ConcatReference } from './udh.ts'; + +-export type { +- CloseOptions, +- MessageDlr, +- ReconnectOptions, +- SendOptions, +- SendSmsOptions, +- SendSmsResult, +- SessionEvents, +- SessionOptions, +-}; +-export type { BindType }; +-export { bindCommands, defaultSystemId }; ++/** A response carries the request's sequence number, which only sendReturn() has. */ ++function misuse(input: PduObjectInput): Error | undefined { ++ return input.cmdName.endsWith('_resp') ++ ? new Error(`Use sendReturn() for responses, not send(): ${input.cmdName}`) ++ : undefined; ++} + + /** A listener may return a promise: an `async` one that rejects is routed like one that throws. */ + type SessionListener = (...args: SessionEvents[K]) => unknown; + ++/** ++ * One socket's life as an SMPP session: it is bound once, ends once when the socket closes, and ++ * never comes back. `close()` and `unbind()` drain first; the peer dropping the link does not. ++ */ + export class Session extends EventEmitter { + declare addListener: (event: K, listener: SessionListener) => this; + declare off: (event: K, listener: SessionListener) => this; +@@ -51,21 +48,23 @@ export class Session extends EventEmitter { + declare prependOnceListener: (event: K, listener: SessionListener) => this; + declare removeListener: (event: K, listener: SessionListener) => this; + ++ /** Which end of the link this is: `server()` builds the `smsc` end. */ ++ readonly linkEnd: LinkEnd; + readonly log: SmppLog; +- +- /** Which end of the link this is. `server()` sets it; a hand-wired SMSC must set it too. */ +- linkEnd: LinkEnd = 'esme'; ++ readonly sock: Socket; + userData: unknown = undefined; + + private bind: SessionBind | undefined = undefined; +- + private readonly concatReference = new ConcatReference(); +- private readonly dlrMerger: DlrMerger; ++ private readonly ended: Promise; ++ private endedResolve: () => void = () => undefined; + private readonly incoming: IncomingRequests; +- private readonly link: LinkLife; + private readonly options: SessionOptions; + private readonly outgoing: OutgoingRequests; +- private readonly reconnectLoop: ReconnectLoop | undefined; ++ /** The socket closed: nothing goes out or comes in after this. */ ++ private over = false; ++ /** close() or unbind() was called: no new sends from the application. */ ++ private stopping = false; + private readonly timers: LinkTimers; + private readonly transport: PduTransport; + +@@ -93,63 +92,47 @@ export class Session extends EventEmitter { + reason: unknown, + ...args: [event: keyof SessionEvents, ...rest: unknown[]] + ): void { +- const [event, ...rest] = args; ++ const [event] = args; + const error = errorFrom(reason); + + this.log.error('session - a listener rejected', { event, message: error.message }); + +- if (event === 'sms') this.incoming.listenerRejected(rest[0]); +- + if (event !== 'sessionError') this.emit('sessionError', error); + } + + constructor(options: SessionOptions) { + super({ captureRejections: true }); + ++ this.linkEnd = options.linkEnd ?? 'esme'; + this.log = guardedLog(options.log); + this.options = options; +- this.dlrMerger = new DlrMerger({ log: this.log, max: defaults.maxDlrMerges, timeout: defaults.dlrMergeTimeout }); +- this.reconnectLoop = this.loopFor(options.reconnect); +- +- const responseTimeout = options.responseTimeout ?? defaults.responseTimeout; +- +- this.link = new LinkLife({ log: this.log, reconnects: this.reconnectLoop !== undefined, timeout: responseTimeout }); ++ this.sock = options.sock; ++ this.ended = new Promise(resolve => { this.endedResolve = resolve; }); + this.timers = new LinkTimers({ + enquireLinkInterval: options.enquireLinkInterval, + 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.sock.destroy(); }, + }); + this.transport = this.transportFor(options.sock); + this.outgoing = new OutgoingRequests({ +- link: this.link, + log: this.log, + maxOutstanding: options.maxOutstanding ?? defaults.maxOutstanding, +- responseTimeout, ++ responseTimeout: options.responseTimeout ?? defaults.responseTimeout, + transport: this.transport, + }); +- this.incoming = new IncomingRequests({ +- dlrMerger: this.dlrMerger, +- link: this.link, ++ this.incoming = new IncomingRequests(this.port(), { + log: this.log, + maxOctets: options.maxOctets, + maxReassembly: options.maxReassembly, + onRequest: options.onRequest, ++ onSms: options.onSms, + 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. */ +- get sock(): Socket { +- return this.transport.sock; ++ this.timers.reset(); + } + + /** The role the ESME bound with, whichever end of the link this is. Undefined before any bind. */ +@@ -162,7 +145,12 @@ export class Session extends EventEmitter { + return this.bind?.peerVersion; + } + +- /** Records a bind this link accepted or had accepted, until the next one. */ ++ /** Whether the socket has closed. */ ++ get closed(): boolean { ++ return this.over; ++ } ++ ++ /** Records the bind this link accepted or had accepted. */ + bound(bindType: string, declaredVersion: unknown): VoidResult { + const checked = checkedBind(bindType, declaredVersion); + +@@ -183,6 +171,13 @@ export class Session extends EventEmitter { + + /** Sends a request and resolves with the peer's response. */ + send(input: PduObjectInput, options: SendOptions = {}): Promise> { ++ // Ahead of the drain, so a misuse is named as one rather than blamed on the shutdown. ++ const wrong = misuse(input); ++ ++ if (wrong) return Promise.resolve({ err: wrong }); ++ if (this.over) return Promise.resolve({ err: new Error('Session is closed') }); ++ if (this.stopping) return Promise.resolve({ err: new Error('Session is shutting down') }); ++ + return this.outgoing.request(input, options); + } + +@@ -196,37 +191,17 @@ export class Session extends EventEmitter { + 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 { + if (!this.bindAllows('submit_sm')) { + return unsent(new Error('A receiver-bound session does not carry submit_sm')); + } + +- const sent = await submitSms({ ++ return submitSms({ + log: this.log, + reference: this.concatReference.next(), + respIdNotation: this.options.smsIdFormat?.submitResp, + send: input => this.send(input, options), + }, sms); +- +- if (!sent.err && sms.dlr === true) this.dlrMerger.expect(sent.smsIds); +- +- return sent; + } + + /** +@@ -235,25 +210,24 @@ export class Session extends EventEmitter { + */ + async unbind(): Promise { + const drained = await this.drain(undefined); +- const wasOpen = this.link.isAttached(); +- const sent = wasOpen +- ? await this.outgoing.requestOnCurrentLink({ cmdName: 'unbind' }) +- : { err: new Error('Session is closed') }; +- const closedOnUnbind = wasOpen && !this.link.isAttached(); ++ const sent = this.over ++ ? { err: new Error('Session is closed') } ++ : await this.outgoing.request({ cmdName: 'unbind' }, {}); ++ const droppedOnUnbind = sent.err instanceof UnansweredError && this.over; + +- this.end(); ++ await this.finish(); + +- return sent.err && !closedOnUnbind ? { err: sent.err } : drained; ++ return sent.err && !droppedOnUnbind ? { err: sent.err } : drained; + } + + /** +- * Closes for good: refuses new sends, waits up to `shutdownTimeout` for the requests already sent +- * and the messages not yet answered, then tears down whatever is left. A session closed this way never reconnects. ++ * Closes for good: refuses new sends, waits up to `shutdownTimeout` for the handlers still ++ * running and the requests already sent, then destroys the socket. + */ + async close(options: CloseOptions = {}): Promise { + const drained = await this.drain(options.signal); + +- this.end(); ++ await this.finish(); + + return drained; + } +@@ -261,138 +235,100 @@ export class Session extends EventEmitter { + private transportFor(sock: Socket): PduTransport { + return new PduTransport({ + log: this.log, +- onClose: () => { this.onClose(); }, +- onData: chunk => { this.onData(chunk); }, ++ onClose: () => { this.end(); }, ++ onData: chunk => { ++ this.emit('data', chunk); ++ this.timers.reset(); ++ }, + onError: err => { this.emit('sessionError', err); }, + onFramed: pdu => { this.emit('incomingPdu', pdu); }, + onPdu: pduObj => { this.dispatch(pduObj); }, + onRefused: refused => { this.refuse(refused); }, + onUnreadable: err => { + this.emit('sessionError', err); +- this.teardown(); ++ this.sock.destroy(); + }, + }, sock); + } + +- private loopFor(reconnect: ReconnectOptions | undefined): ReconnectLoop | undefined { +- if (!reconnect) return undefined; +- +- return new ReconnectLoop({ +- connect: reconnect.connect, +- log: this.log, +- maxDelay: reconnect.maxDelay, +- minDelay: reconnect.minDelay, +- onConnected: sock => this.comeBackUp(sock, reconnect.onConnected), +- }); ++ /** What the peer's requests may do to this session, and no more. */ ++ private port(): SessionPort { ++ return { ++ acceptsOptionalParams: () => this.acceptsOptionalParams(), ++ answer: (pduObj, status, params, tlvs) => this.sendReturn(pduObj, status, params, tlvs), ++ bindAllows: cmdName => this.bindAllows(cmdName), ++ boundAs: () => this.boundAs, ++ closed: () => this.over, ++ end: () => { ++ this.stopping = true; ++ this.sock.destroy(); ++ }, ++ linkEnd: this.linkEnd, ++ onDlr: (dlr, pduObj) => { this.emit('dlr', dlr, pduObj); }, ++ report: err => { this.emit('sessionError', err); }, ++ request: input => this.outgoing.request(input, {}), ++ session: this, ++ }; + } + +- private async comeBackUp( +- sock: Socket, +- bind: (session: Session) => Promise, +- ): Promise { +- this.attach(sock); +- +- const bound = await bind(this); +- +- if (bound.err) { +- this.teardown(); +- +- return { err: bound.err }; +- } +- +- // close() can land while the rebind is in flight. +- if (!this.link.retrying()) { +- this.teardown(); ++ private answer(built: Result<{ buffer: Buffer }>, cmdName: string, seqNr: number): VoidResult { ++ const sent = built.err ? { err: built.err } : this.transport.write(built.buffer); + +- return { err: new Error('Session closed while it was coming back up') }; ++ // A peer that unbinds and drops the link takes our response with it; that is not a failure. ++ if (sent.err && !this.over) { ++ this.log.warn('session - could not answer a request', { ++ cmdName, ++ message: sent.err.message, ++ seqNr, ++ }); ++ this.emit('sessionError', sent.err); + } + +- 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(); ++ return sent; + } + +- /** Stops new sends and waits out the messages we hold and the requests already issued. */ ++ /** Stops new sends and waits out the handlers still running and the requests already issued. */ + private async drain(signal: AbortSignal | undefined): Promise { +- this.stop(); ++ this.stopping = true; + +- // No bound link, so nothing is on the wire to wait out. +- if (!this.outgoing.canCarry()) return {}; ++ if (this.over) 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 handlers = await this.incoming.drain(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 (this.closed) return { err: new Error('The session closed before the drain finished') }; + +- if (!messages.err) return requests; ++ if (!handlers.err) return requests; + +- if (!requests.err) return messages; ++ if (!requests.err) return handlers; + +- return { err: new Error(`${messages.err.message}; ${requests.err.message}`) }; ++ return { err: new Error(`${handlers.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; ++ /** Destroys the socket and waits for its close, which is where the session ends. */ ++ private async finish(): Promise { ++ this.sock.destroy(); + +- const responseTimeout = this.options.responseTimeout ?? defaults.responseTimeout; ++ // A socket that closed before this session watched it never tells it so. ++ if (this.sock.closed) this.end(); + +- return responseTimeout > 0 ? responseTimeout : defaults.responseTimeout; ++ await this.ended; + } + +- /** The session is over now, drained or not. Nothing brings it back. */ ++ /** The socket is gone. Runs once, from its close event: nothing else ends a session. */ + 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(); +- } ++ if (this.over) return; + +- 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.over = true; + 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(); +- } +- +- private onData(chunk: Buffer): void { +- this.emit('data', chunk); +- this.resetTimers(); ++ this.outgoing.end(); ++ this.incoming.end(); ++ this.emit('close'); ++ this.endedResolve(); + } + + private dispatch(pduObj: PduObject): void { +@@ -405,7 +341,7 @@ export class Session extends EventEmitter { + } + + this.emit('incomingPduObj', pduObj); +- // Every application hook and listener reached from an incoming PDU funnels through here. ++ // Every application hook reached from an incoming PDU funnels through here. + void this.incoming.handle(pduObj).catch((thrown: unknown) => { + const err = errorFrom(thrown); + +@@ -433,21 +369,4 @@ export class Session extends EventEmitter { + + 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/src/sms.ts b/src/sms.ts +index 5149ceb..1b45466 100644 +--- a/src/sms.ts ++++ b/src/sms.ts +@@ -1,5 +1,6 @@ + import type { ErrorName } from './defs/errors.ts'; + import type { MessageState } from './defs/constants.ts'; ++import type { ParamValue } from './defs/types.ts'; + import type { PduObject, PduObjectInput, TlvInputs } from './pdu.ts'; + import type { Result, VoidResult } from './result.ts'; + import type { Session } from './session.ts'; +@@ -21,16 +22,14 @@ export type SendDlrResult = { + unanswered: number; + }; + ++/** How a message is answered: the id the peer correlates a receipt by, or the status refusing it. */ + export type SendRespOptions = { +- /** The id the peer correlates a later delivery receipt by. Defaults to a generated UUID v7. */ ++ /** Defaults to a generated UUID v7. */ + smsId?: string; + status?: ErrorName; + }; + +-/** +- * A received SMS, and the handle for answering it. Multipart messages arrive as one Sms carrying +- * every segment's PDU. +- */ ++/** A received SMS, and the handle for answering and reporting on it. */ + export type Sms = { + /** + * Whether the peer was answered as the message's segments arrived, which is what a concatenated +@@ -43,16 +42,16 @@ export type Sms = { + from: string; + message: string; + pduObjs: PduObject[]; +- /** Sends a delivery report back to the sender. Defaults to DELIVERED. */ ++ /** Sends a delivery report back to the sender, once the message is answered. Defaults to DELIVERED. */ + sendDlr: (status?: MessageState) => Promise; + /** +- * Answers the message, and says the application is done with it. A concatenated message was +- * answered segment by segment as it arrived, so there it only releases a shutdown's wait and +- * refuses an `smsId` or a refusing `status`. Part of the protocol, not optional. ++ * Answers the message now, for a handler that keeps working after the answer. Returning from ++ * `onSms` answers whatever this has not. Writes once; after that an `smsId` or a refusing ++ * `status` is refused and a bare call is a no-op. + */ + sendResp: (options?: SendRespOptions) => Promise; + session: Session; +- /** The id the segments were answered with, the id `sendResp()` was given, or a generated UUID v7. */ ++ /** The id the message is answered with: the segments' base, the one `sendResp()` was given, or a generated UUID v7. */ + readonly smsId: string; + submitTime: Date; + to: string; +@@ -65,20 +64,24 @@ export type SmsInput = { + session: Session; + }; + +-export type SmsHandlers = { +- answered: () => void; +- lostLink: () => boolean; +- send: (input: PduObjectInput) => Promise>; ++/** What answering and reporting need from the session the message arrived on. */ ++export type SmsDeps = { ++ acceptsOptionalParams: () => boolean; ++ answer: (pduObj: PduObject, status: ErrorName, params: Record) => Promise; ++ bindAllows: (cmdName: string) => boolean; ++ request: (input: PduObjectInput) => Promise>; + }; + ++type Answer = { answered: boolean; smsId: string }; ++ + /** GSM 03.38 section 4 gives class 0 immediate display; every other class is stored somewhere. */ + const immediateDisplayClass = 0; + +-export function createSms(input: SmsInput, handlers: SmsHandlers): Sms { ++export function createSms(input: SmsInput, deps: SmsDeps): Sms { + const first = input.pduObjs[0]; + const registered = first?.params.registered_delivery; + const dataCoding = first?.params.data_coding; +- const answered = { smsId: input.answeredAs ?? uuidv7() }; ++ const answer: Answer = { answered: input.answeredAs !== undefined, smsId: input.answeredAs ?? uuidv7() }; + + const sms: Sms = { + answeredOnArrival: input.answeredAs !== undefined, +@@ -87,13 +90,11 @@ export function createSms(input: SmsInput, handlers: SmsHandlers): Sms { + from: paramText(first?.params.source_addr), + message: decodeSegments(input.pduObjs), + pduObjs: input.pduObjs, +- sendDlr: status => sendDlr(sms, input.session, handlers, status), +- sendResp: options => (input.answeredAs === undefined +- ? sendResp(sms, input.session, answered, options ?? {}, handlers) +- : answeredOnArrival(options ?? {}, handlers)), ++ sendDlr: status => sendDlr(sms, answer, deps, status), ++ sendResp: options => sendResp(sms, answer, deps, options ?? {}), + session: input.session, + get smsId(): string { +- return answered.smsId; ++ return answer.smsId; + }, + submitTime: new Date(), + to: paramText(first?.params.destination_addr), +@@ -102,63 +103,44 @@ export function createSms(input: SmsInput, handlers: SmsHandlers): Sms { + return sms; + } + +-/** Every segment went out answered, so the call is what the shutdown waits for and nothing else. */ +-function answeredOnArrival( +- options: SendRespOptions, +- handlers: Pick, +-): Promise { ++function alreadyAnswered(sms: Sms, options: SendRespOptions): VoidResult { + if (options.smsId !== undefined) { +- return Promise.resolve({ +- err: new Error('This message\'s id was fixed when its first segment arrived; read sms.smsId'), +- }); ++ return { ++ err: new Error(sms.answeredOnArrival ++ ? 'This message\'s id was fixed when its first segment arrived; read sms.smsId' ++ : 'This message is already answered, under sms.smsId'), ++ }; + } + + if (options.status !== undefined && options.status !== 'ESME_ROK') { +- return Promise.resolve({ +- err: new Error('Its segments were answered as they arrived, so there is nothing left to refuse; refuse a segment from the onRequest option instead'), +- }); ++ return { ++ err: new Error(sms.answeredOnArrival ++ ? 'Its segments were answered as they arrived, so there is nothing left to refuse; refuse a segment from the onRequest option instead' ++ : 'This message is already answered, so there is nothing left to refuse'), ++ }; + } + +- handlers.answered(); +- +- return Promise.resolve({}); ++ return {}; + } + +-async function sendResp( +- sms: Sms, +- session: Session, +- answered: { smsId: string }, +- options: SendRespOptions, +- handlers: Pick, +-): Promise { +- const total = sms.pduObjs.length; ++async function sendResp(sms: Sms, answer: Answer, deps: SmsDeps, options: SendRespOptions): Promise { ++ if (answer.answered) return alreadyAnswered(sms, options); + +- if (total === 0) { +- return { err: new Error('No PDUs to answer') }; +- } ++ if (options.smsId === '') return { err: new Error('smsId must not be empty') }; + +- if (options.smsId === '') { +- return { err: new Error('smsId must not be empty') }; +- } +- +- if (options.smsId !== undefined) answered.smsId = options.smsId; ++ const first = sms.pduObjs[0]; + +- // A response carries the sequence number it was asked on, which the next link knows nothing about. +- if (handlers.lostLink()) { +- return { err: new Error('The link this message arrived on is gone, so nothing would correlate the response') }; +- } ++ if (!first) return { err: new Error('No PDUs to answer') }; + +- const results = await Promise.all(sms.pduObjs.map((pduObj, index) => session.sendReturn( +- pduObj, +- options.status ?? 'ESME_ROK', +- respIdParams(pduObj.cmdName, segmentId(answered.smsId, index, total)), +- ))); ++ const smsId = options.smsId ?? answer.smsId; ++ const sent = await deps.answer(first, options.status ?? 'ESME_ROK', respIdParams(first.cmdName, smsId)); + +- const failure = results.find(result => result.err); ++ if (sent.err) return sent; + +- if (!failure) handlers.answered(); ++ answer.answered = true; ++ answer.smsId = smsId; + +- return failure ?? {}; ++ return {}; + } + + /** The receipt as text, which is all of it a peer below SMPP 3.4 is allowed to be sent. */ +@@ -207,26 +189,31 @@ function collectReceipt(sent: Result<{ pduObj: PduObject }>[]): SendDlrResult { + return failure ? { err: failure, pduObjs, unanswered } : { pduObjs, unanswered }; + } + ++function unreported(err: Error): SendDlrResult { ++ return { err, pduObjs: [], unanswered: 0 }; ++} ++ + async function sendDlr( + sms: Sms, +- session: Session, +- handlers: Pick, ++ answer: Answer, ++ deps: SmsDeps, + status: MessageState = 'DELIVERED', + ): Promise { +- if (!session.bindAllows('deliver_sm')) { +- return { +- err: new Error('A transmitter-bound session does not carry deliver_sm'), +- pduObjs: [], +- unanswered: 0, +- }; ++ if (!deps.bindAllows('deliver_sm')) { ++ return unreported(new Error('A transmitter-bound session does not carry deliver_sm')); ++ } ++ ++ // A receipt names a message the peer had accepted, so it cannot precede the answer. ++ if (!answer.answered) { ++ return unreported(new Error('Answer the message before reporting on it: sendResp() first, or return from onSms and report later')); + } + + const total = sms.pduObjs.length; + // Together, not one after a response: a drain waiting for this message must see the whole receipt. + const sent = await Promise.all(sms.pduObjs.map((_segment, index) => { +- const smsId = segmentId(sms.smsId, index, total); ++ const smsId = segmentId(answer.smsId, index, total); + +- return handlers.send({ ++ return deps.request({ + cmdName: 'deliver_sm', + params: { + destination_addr: sms.from, +@@ -236,8 +223,9 @@ async function sendDlr( + short_message: receiptText(sms, smsId, status), + source_addr: sms.to, + }, +- ...(session.acceptsOptionalParams() ? { tlvs: receiptTlvs(smsId, status) } : {}), ++ ...(deps.acceptsOptionalParams() ? { tlvs: receiptTlvs(smsId, status) } : {}), + }); + })); ++ + return collectReceipt(sent); + } +diff --git a/test/declared-alphabet.test.ts b/test/declared-alphabet.test.ts +index 8132883..089c7ff 100644 +--- a/test/declared-alphabet.test.ts ++++ b/test/declared-alphabet.test.ts +@@ -80,8 +80,8 @@ function delivered(body: Buffer | string, dataCoding: number, esmClass: number): + describe('the alphabet a message declares is the one its octets are written in', () => { + test('sends GSM 03.38 under data_coding 0x00, the SMSC default alphabet', async t => { + const smsc = await dummySmsc(t, { messageIds: ['01a08779-de97-7caa-9d26-e6d50f5c4888'] }); +- const session = await bindToSmsc(t, smsc.port, { reconnect: false }); +- const sent = await session.sendSms({ from, message: bothTables, to }); ++ const esme = await bindToSmsc(t, smsc.port, { reconnect: false }); ++ const sent = await esme.sendSms({ from, message: bothTables, to }); + + assert.equal(sent.err, undefined); + assert.deepEqual(submitted(smsc.octets).map(declaredBy), [0x00]); +@@ -89,9 +89,9 @@ describe('the alphabet a message declares is the one its octets are written in', + + test('keeps $ and @ for a peer that honours the declaration, where IA5 read STX and NUL', async t => { + const smsc = await dummySmsc(t, { messageIds: ['01a08779-de98-7d24-9542-e652e0d3761c'] }); +- const session = await bindToSmsc(t, smsc.port, { reconnect: false }); ++ const esme = await bindToSmsc(t, smsc.port, { reconnect: false }); + +- assert.equal((await session.sendSms({ from, message: bothTables, to })).err, undefined); ++ assert.equal((await esme.sendSms({ from, message: bothTables, to })).err, undefined); + + const [pduObj] = submitted(smsc.octets); + +@@ -112,38 +112,39 @@ describe('the alphabet a message declares is the one its octets are written in', + const smsc = await dummySmsc(t, { + messageIds: ['01a08779-de99-7fa5-bcac-18feef55aeee', '01a08779-de99-72ec-8dbb-9365a00158c3'], + }); +- const session = await bindToSmsc(t, smsc.port, { reconnect: false }); ++ const esme = await bindToSmsc(t, smsc.port, { reconnect: false }); + +- assert.equal((await session.sendSms({ encoding: 'LATIN1', from, message: 'Räksmörgås', to })).err, undefined); +- assert.equal((await session.sendSms({ from, message: 'あいう', to })).err, undefined); ++ assert.equal((await esme.sendSms({ encoding: 'LATIN1', from, message: 'Räksmörgås', to })).err, undefined); ++ assert.equal((await esme.sendSms({ from, message: 'あいう', to })).err, undefined); + assert.deepEqual(submitted(smsc.octets).map(declaredBy), [0x03, 0x08]); + }); + + // sendDlr() writes its body as a string with no data_coding, so it takes the detected branch too. + test('declares 0x00 on a receipt it writes itself', async t => { +- const { err, server: smpp } = await server({ port: 0 }); ++ const { err, server: smpp } = await server({ ++ onSms: async sms => { ++ await sms.sendResp(); ++ await sms.sendDlr('DELIVERED'); ++ }, ++ port: 0, ++ }); + + assert.equal(err, undefined); + assert.ok(smpp); + closeAfter(t, smpp); + +- smpp.on('session', peer => peer.on('sms', async sms => { +- await sms.sendResp(); +- await sms.sendDlr('DELIVERED'); +- })); +- + const connected = await client({ port: smpp.port, reconnect: false }); + + assert.equal(connected.err, undefined); +- assert.ok(connected.session); +- closeAfter(t, connected.session); ++ assert.ok(connected.client); ++ closeAfter(t, connected.client); + +- const session = connected.session; ++ const esme = connected.client; + const reported = once(resolve => { +- session.on('dlr', (_report, pduObj) => { resolve(pduObj); }); ++ esme.on('dlr', (_report, pduObj) => { resolve(pduObj); }); + }); + +- assert.equal((await session.sendSms({ dlr: true, from, message: bothTables, to })).err, undefined); ++ assert.equal((await esme.sendSms({ dlr: true, from, message: bothTables, to })).err, undefined); + assert.equal(declaredBy(await reported), 0x00); + }); + +diff --git a/test/dummy-smsc.ts b/test/dummy-smsc.ts +index 7da2609..c1f0910 100644 +--- a/test/dummy-smsc.ts ++++ b/test/dummy-smsc.ts +@@ -1,6 +1,6 @@ + import assert from 'node:assert/strict'; + import net from 'node:net'; +-import type { Session } from '../src/session.ts'; ++import type { SmppClient } from '../src/client.ts'; + import type { TestContext } from 'node:test'; + import { PduFramer } from '../src/pdu-framer.ts'; + import { client } from '../src/client.ts'; +@@ -93,12 +93,12 @@ export async function bindToSmsc( + t: TestContext, + port: number, + options: Parameters[0] = {}, +-): Promise { +- const { err, session } = await client({ ...options, port }); ++): Promise { ++ const { err, client: esme } = await client({ ...options, port }); + + assert.equal(err, undefined); +- assert.ok(session); +- closeAfter(t, session); ++ assert.ok(esme); ++ closeAfter(t, esme); + +- return session; ++ return esme; + } +diff --git a/test/interop.test.ts b/test/interop.test.ts +index 2e5d905..c8e9134 100644 +--- a/test/interop.test.ts ++++ b/test/interop.test.ts +@@ -230,14 +230,14 @@ describe('a live session against the reference implementation', () => { + await new Promise(resolve => { refServer.listen(0, () => { resolve(); }); }); + + const port = refServer.address()?.port ?? 0; +- const { err, session } = await client({ port }); ++ const { err, client: esme } = await client({ port }); + + assert.equal(err, undefined); +- assert.ok(session); +- closeAfter(t, session); ++ assert.ok(esme); ++ closeAfter(t, esme); + t.after(() => new Promise(resolve => { refServer.close(() => { resolve(); }); })); + +- const sent = await session.sendSms({ ++ const sent = await esme.sendSms({ + from: 'MyBrand', + message: 'interop check', + to: '46709771337', +@@ -249,16 +249,14 @@ describe('a live session against the reference implementation', () => { + }); + + test('a reference client binds to our server and delivers an SMS', async t => { +- const { err: serverErr, server: smpp } = await server({ port: 0 }); ++ const incoming: { resolve?: (sms: Sms) => void } = {}; ++ const arrived = new Promise(resolve => { incoming.resolve = resolve; }); ++ const { err: serverErr, server: smpp } = await server({ onSms: sms => { incoming.resolve?.(sms); }, port: 0 }); + + assert.equal(serverErr, undefined); + assert.ok(smpp); + closeAfter(t, smpp); + +- const incoming = new Promise(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); +- + const refSession = reference.connect({ + url: `smpp://localhost:${String(smpp.port)}`, + }); +@@ -275,10 +273,9 @@ describe('a live session against the reference implementation', () => { + source_addr: '46701113311', + }); + +- const sms = await incoming; ++ const sms = await arrived; + + assert.equal(sms.from, '46701113311'); + assert.equal(sms.message, 'from the reference client'); +- await sms.sendResp(); + }); + }); +diff --git a/test/message-class.test.ts b/test/message-class.test.ts +index e613139..c6714f1 100644 +--- a/test/message-class.test.ts ++++ b/test/message-class.test.ts +@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; + import test, { describe } from 'node:test'; + import type { PduObjectInput } from '../src/pdu.ts'; + import type { SendSmsDeps } from '../src/send-sms.ts'; +-import type { Session } from '../src/session.ts'; ++import type { SmppClient } from '../src/client.ts'; + import type { Sms } from '../src/sms.ts'; + import type { TestContext } from 'node:test'; + import { bindToSmsc, dummySmsc } from './dummy-smsc.ts'; +@@ -19,33 +19,27 @@ const from = '46701113311'; + const to = '46709771337'; + + type MessagePeer = { +- /** Every message the server session was handed, in arrival order. */ ++ esme: SmppClient; ++ /** Every message the server was handed, in arrival order. */ + received: Sms[]; +- session: Session; + }; + + /** A server that answers every message, and a client to write raw submit_sm PDUs at it. */ + async function messagesInto(t: TestContext): Promise { + const received: Sms[] = []; +- const { err, server: smpp } = await server({ port: 0 }); ++ const { err, server: smpp } = await server({ onSms: sms => { received.push(sms); }, port: 0 }); + + assert.equal(err, undefined); + assert.ok(smpp); + closeAfter(t, smpp); + +- smpp.on('session', peer => peer.on('sms', sms => { +- received.push(sms); +- +- return sms.sendResp(); +- })); +- + const connected = await client({ port: smpp.port, reconnect: false }); + + assert.equal(connected.err, undefined); +- assert.ok(connected.session); +- closeAfter(t, connected.session); ++ assert.ok(connected.client); ++ closeAfter(t, connected.client); + +- return { received, session: connected.session }; ++ return { esme: connected.client, received }; + } + + function dataCodingsOf(octets: Buffer[]): number[] { +@@ -116,7 +110,7 @@ describe('an inbound message', () => { + ]; + + for (const [dataCoding] of codings) { +- const sent = await peer.session.send({ ++ const sent = await peer.esme.send({ + cmdName: 'submit_sm', + params: { + data_coding: dataCoding, +@@ -135,7 +129,7 @@ describe('an inbound message', () => { + + test('keeps the alphabet its class group declares, so a flash UCS2 message still reads as UCS2', async t => { + const peer = await messagesInto(t); +- const sent = await peer.session.send({ ++ const sent = await peer.esme.send({ + cmdName: 'submit_sm', + params: { + data_coding: 0x18, +@@ -157,7 +151,7 @@ describe('an inbound message', () => { + describe('sendSms() flash', () => { + test('writes the message class into data_coding beside the alphabet, never over it', async t => { + const smsc = await dummySmsc(t); +- const session = await bindToSmsc(t, smsc.port, { reconnect: false }); ++ const esme = await bindToSmsc(t, smsc.port, { reconnect: false }); + const sends = [ + { from, message: 'Hello world', to }, + { flash: true, from, message: 'Hello world', to }, +@@ -167,7 +161,7 @@ describe('sendSms() flash', () => { + ] as const; + + for (const send of sends) { +- assert.equal((await session.sendSms(send)).err, undefined, send.message); ++ assert.equal((await esme.sendSms(send)).err, undefined, send.message); + } + + // 0.4.0 forced 0x10 whatever the alphabet was, which mangled every non-GSM flash message. +diff --git a/test/messaging-mode.test.ts b/test/messaging-mode.test.ts +index 4ac57ee..bfbe2ee 100644 +--- a/test/messaging-mode.test.ts ++++ b/test/messaging-mode.test.ts +@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; + import test, { describe } from 'node:test'; + import type { PduObjectInput } from '../src/pdu.ts'; + import type { SendSmsDeps } from '../src/send-sms.ts'; +-import type { Session } from '../src/session.ts'; ++import type { SmppClient } from '../src/client.ts'; + import type { SubmitMessagingMode } from '../src/defs/constants.ts'; + import type { TestContext } from 'node:test'; + import { bindToSmsc, dummySmsc } from './dummy-smsc.ts'; +@@ -31,14 +31,14 @@ const threeSegmentOctets = [ + type BoundPeer = { + /** Every submit_sm the ESME wrote, exactly as it arrived on the socket. */ + octets: Buffer[]; +- session: Session; ++ esme: SmppClient; + }; + + async function boundToPeer(t: TestContext): Promise { + const smsc = await dummySmsc(t); +- const session = await bindToSmsc(t, smsc.port, { reconnect: false }); ++ const esme = await bindToSmsc(t, smsc.port, { reconnect: false }); + +- return { octets: smsc.octets, session }; ++ return { esme, octets: smsc.octets }; + } + + function hexOf(octets: Buffer[]): string[] { +@@ -71,7 +71,7 @@ function esmClassesOf(octets: Buffer[]): number[] { + describe('sendSms() with no messagingMode', () => { + test('writes a single-segment message as the octets it has always written', async t => { + const peer = await boundToPeer(t); +- const sent = await peer.session.sendSms({ from, message: 'Hello world', to }); ++ const sent = await peer.esme.sendSms({ from, message: 'Hello world', to }); + + assert.equal(sent.err, undefined); + assert.deepEqual(hexOf(peer.octets), [singleSegmentOctets]); +@@ -79,7 +79,7 @@ describe('sendSms() with no messagingMode', () => { + + test('writes every segment of a three-segment message as the octets it has always written', async t => { + const peer = await boundToPeer(t); +- const sent = await peer.session.sendSms({ from, message: longMessage, to }); ++ const sent = await peer.esme.sendSms({ from, message: longMessage, to }); + + assert.equal(sent.err, undefined); + assert.equal(sent.smsIds.length, 3); +@@ -88,13 +88,13 @@ describe('sendSms() with no messagingMode', () => { + + test('writes what SMSC_DEFAULT writes, octet for octet, single-segment and multipart alike', async t => { + const one = await boundToPeer(t); +- const single = await one.session.sendSms({ from, message: 'Hello world', messagingMode: 'SMSC_DEFAULT', to }); ++ const single = await one.esme.sendSms({ from, message: 'Hello world', messagingMode: 'SMSC_DEFAULT', to }); + + assert.equal(single.err, undefined); + assert.deepEqual(hexOf(one.octets), [singleSegmentOctets]); + + const many = await boundToPeer(t); +- const long = await many.session.sendSms({ from, message: longMessage, messagingMode: 'SMSC_DEFAULT', to }); ++ const long = await many.esme.sendSms({ from, message: longMessage, messagingMode: 'SMSC_DEFAULT', to }); + + assert.equal(long.err, undefined); + assert.deepEqual(hexOf(many.octets), threeSegmentOctets); +@@ -111,8 +111,8 @@ describe('sendSms() messagingMode', () => { + for (const messagingMode of modes) { + const peer = await boundToPeer(t); + const bits = consts.MESSAGING_MODE[messagingMode]; +- const single = await peer.session.sendSms({ from, message: 'Hello world', messagingMode, to }); +- const long = await peer.session.sendSms({ from, message: longMessage, messagingMode, to }); ++ const single = await peer.esme.sendSms({ from, message: 'Hello world', messagingMode, to }); ++ const long = await peer.esme.sendSms({ from, message: longMessage, messagingMode, to }); + + assert.equal(single.err, undefined); + assert.equal(long.err, undefined); +diff --git a/test/operator-receipts.test.ts b/test/operator-receipts.test.ts +index 36dd655..9fefae0 100644 +--- a/test/operator-receipts.test.ts ++++ b/test/operator-receipts.test.ts +@@ -1,7 +1,7 @@ + import assert from 'node:assert/strict'; + import test, { describe } from 'node:test'; + import type { Dlr, Receipt } from '../src/dlr.ts'; +-import type { MessageDlr } from '../src/session.ts'; ++import type { MessageDlr } from '../src/dlr-merger.ts'; + import type { PduObject, TlvInputs } from '../src/pdu.ts'; + import { bindToSmsc, dummySmsc } from './dummy-smsc.ts'; + import { consts } from '../src/defs/constants.ts'; +diff --git a/test/readme.test.ts b/test/readme.test.ts +index f2198a5..8ba2252 100644 +--- a/test/readme.test.ts ++++ b/test/readme.test.ts +@@ -26,21 +26,20 @@ function once(register: (resolve: (value: T) => void) => void): Promise { + } + + /** The README's examples listen on the documented default port, so one server runs at a time. */ +-async function answeringServer(t: TestContext): Promise { +- const { err, server: smpp } = await server(); +- +- assert.equal(err, undefined); +- assert.ok(smpp); +- closeAfter(t, smpp); +- +- smpp.on('session', session => { +- session.on('sms', async sms => { ++async function answeringServer(t: TestContext, onSms: (sms: Sms) => void = () => undefined): Promise { ++ const { err, server: smpp } = await server({ ++ onSms: async sms => { ++ onSms(sms); + await sms.sendResp(); + + if (sms.dlr) await sms.sendDlr(); +- }); ++ }, + }); + ++ assert.equal(err, undefined); ++ assert.ok(smpp); ++ closeAfter(t, smpp); ++ + return smpp; + } + +@@ -48,18 +47,18 @@ describe('README: Client', () => { + test('the simplest possible client', async t => { + await answeringServer(t); + +- const { err, session } = await client(); ++ const { err, client: smpp } = await client(); + if (err) throw err; + +- closeAfter(t, session); ++ closeAfter(t, smpp); + +- await session.sendSms({ ++ await smpp.sendSms({ + from: '46701113311', + message: 'Hello world', + to: '46709771337', + }); + +- await session.unbind(); ++ await smpp.unbind(); + }); + + test('with connection parameters, a delivery report and logging', async t => { +@@ -73,7 +72,7 @@ describe('README: Client', () => { + warn: () => undefined, + }; + +- const { err, session } = await client({ ++ const { err, client: smpp } = await client({ + host: 'localhost', + log, + password: 'bar', +@@ -82,10 +81,10 @@ describe('README: Client', () => { + }); + if (err) throw err; + +- closeAfter(t, session); ++ closeAfter(t, smpp); + +- const reported = once(resolve => { session.on('dlr', resolve); }); +- const { err: sendErr, smsIds, unanswered } = await session.sendSms({ ++ const reported = once(resolve => { smpp.on('dlr', resolve); }); ++ const { err: sendErr, smsIds, unanswered } = await smpp.sendSms({ + dlr: true, + from: '46701113311', + message: '«baff»', +@@ -101,13 +100,13 @@ describe('README: Client', () => { + test('naming the notation the SMSC writes message ids in', async t => { + await answeringServer(t); + +- const { err, session } = await client({ smsIdFormat: { receipt: 'decimal', submitResp: 'hex' } }); ++ const { err, client: smpp } = await client({ smsIdFormat: { receipt: 'decimal', submitResp: 'hex' } }); + if (err) throw err; + +- closeAfter(t, session); ++ closeAfter(t, smpp); + +- const reported = once(resolve => { session.on('dlr', resolve); }); +- const { err: sendErr, smsIds } = await session.sendSms({ ++ const reported = once(resolve => { smpp.on('dlr', resolve); }); ++ const { err: sendErr, smsIds } = await smpp.sendSms({ + dlr: true, + from: '46701113311', + message: 'Hello world', +@@ -121,48 +120,52 @@ describe('README: Client', () => { + }); + + test('the documented sending options', async t => { +- const smpp = await answeringServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); +- const { err, session } = await client(); ++ let received: Sms | undefined; ++ ++ await answeringServer(t, sms => { received = sms; }); ++ ++ const { err, client: smpp } = await client(); + if (err) throw err; + +- closeAfter(t, session); ++ closeAfter(t, smpp); + + const { signal } = new AbortController(); +- const [sms, sent] = await Promise.all([ +- incoming, +- session.sendSms({ +- dlr: true, +- encoding: 'UCS2', +- flash: false, +- from: 'MyBrand', +- message: 'Hello world', +- messagingMode: 'SMSC_DEFAULT', +- scheduleDeliveryTime: new Date(Date.now() + 3600_000), +- to: '46709771337', +- validityPeriod: 3600, +- }, { signal }), +- ]); ++ const sent = await smpp.sendSms({ ++ dlr: true, ++ encoding: 'UCS2', ++ flash: false, ++ from: 'MyBrand', ++ message: 'Hello world', ++ messagingMode: 'SMSC_DEFAULT', ++ scheduleDeliveryTime: new Date(Date.now() + 3600_000), ++ to: '46709771337', ++ validityPeriod: 3600, ++ }, { signal }); + + assert.equal(sent.err, undefined); +- assert.equal(sms.from, 'MyBrand'); +- assert.equal(sms.message, 'Hello world'); ++ assert.ok(received); ++ assert.equal(received.from, 'MyBrand'); ++ assert.equal(received.message, 'Hello world'); + }); + +- test('receiving an inbound message on a client session', async t => { ++ test('receiving an inbound message on a client', async t => { + const smpp = await answeringServer(t); +- const bound = once(resolve => { smpp.on('session', resolve); }); +- const { err, session } = await client(); ++ const accepted = once(resolve => { smpp.on('session', resolve); }); ++ const handed: { resolve?: (sms: Sms) => void } = {}; ++ const incoming = once(resolve => { handed.resolve = resolve; }); ++ const { err, client: esme } = await client({ ++ onSms: async sms => { ++ // sms.from, sms.to, sms.message ++ handed.resolve?.(sms); ++ await Promise.resolve(); ++ }, ++ }); + if (err) throw err; + +- closeAfter(t, session); ++ closeAfter(t, esme); + +- const incoming = once(resolve => { session.on('sms', resolve); }); +- const peer = await bound; +- +- void peer.send({ ++ const peer = await accepted; ++ const delivered = peer.send({ + cmdName: 'deliver_sm', + params: { + destination_addr: '46709771337', +@@ -170,72 +173,60 @@ describe('README: Client', () => { + source_addr: '46701113311', + }, + }); +- + const sms = await incoming; + +- await sms.sendResp(); +- + assert.equal(sms.message, 'inbound hello'); ++ assert.equal((await delivered).pduObj?.cmdStatus, 'ESME_ROK', 'returning answered it'); + }); + }); + + describe('README: Server', () => { + test('the simplest possible server', async t => { +- const { err, server: smpp } = await server(); +- if (err) throw err; +- +- closeAfter(t, smpp); +- + const received: string[] = []; +- +- smpp.on('session', session => { +- session.on('sms', async sms => { ++ const { err, server: smpp } = await server({ ++ onSms: async sms => { ++ // sms.from, sms.to, sms.message, sms.dlr; returning answers ESME_ROK + received.push(sms.message); +- await sms.sendResp(); +- }); ++ await Promise.resolve(); ++ }, + }); ++ if (err) throw err; + +- const { err: clientErr, session } = await client(); ++ closeAfter(t, smpp); ++ ++ const { err: clientErr, client: esme } = await client(); + + assert.equal(clientErr, undefined); +- assert.ok(session); +- closeAfter(t, session); ++ assert.ok(esme); ++ closeAfter(t, esme); + +- await session.sendSms({ from: '46701113311', message: 'Hello world', to: '46709771337' }); +- await session.unbind(); ++ await esme.sendSms({ from: '46701113311', message: 'Hello world', to: '46709771337' }); ++ await esme.unbind(); + + assert.deepEqual(received, ['Hello world']); + }); + + test('with authentication and delivery reports', async t => { ++ let answeredOnArrival: boolean | undefined; + const { err, server: smpp } = await server({ + authenticate: ({ password, systemId }) => { + if (systemId !== 'foo' || password !== 'bar') return false; + + return { userData: { userId: 123 } }; + }, +- }); +- if (err) throw err; +- +- closeAfter(t, smpp); +- +- let answeredOnArrival: boolean | undefined; +- +- smpp.on('session', session => { +- session.on('sms', async sms => { ++ onSms: async sms => { + answeredOnArrival = sms.answeredOnArrival; +- +- if (sms.answeredOnArrival) { +- await sms.sendResp(); // multipart: only releases the shutdown drain +- } else { +- await sms.sendResp(); // ESME_ROK with a generated id +- } ++ // Answer now rather than on return, since a receipt can only follow the answer. ++ await sms.sendResp(); + + if (sms.dlr) { + await sms.sendDlr(); + } +- }); ++ }, + }); ++ if (err) throw err; ++ ++ closeAfter(t, smpp); + + assert.equal(smpp.port, 2775); + +@@ -243,14 +234,14 @@ describe('README: Server', () => { + + assert.ok(refused.err instanceof Error); + +- const { err: clientErr, session } = await client({ password: 'bar', username: 'foo' }); ++ const { err: clientErr, client: esme } = await client({ password: 'bar', username: 'foo' }); + + assert.equal(clientErr, undefined); +- assert.ok(session); +- closeAfter(t, session); ++ assert.ok(esme); ++ closeAfter(t, esme); + +- const reported = once(resolve => { session.on('dlr', resolve); }); +- const sent = await session.sendSms({ ++ const reported = once(resolve => { esme.on('dlr', resolve); }); ++ const sent = await esme.sendSms({ + dlr: true, + from: '46701113311', + message: 'with a receipt', +@@ -260,10 +251,12 @@ describe('README: Server', () => { + assert.equal(sent.err, undefined); + assert.equal((await reported).statusMsg, 'DELIVERED'); + assert.equal(answeredOnArrival, false); ++ assert.deepEqual([...smpp.sessions][0]?.userData, { userId: 123 }); + }); + + test('refusing a segment at onRequest, before this library would answer it', async t => { + const knownRecipients = new Set(['46709771337']); ++ let messages = 0; + const { err, server: smpp } = await server({ + onRequest: async (session, pduObj) => { + if (!isCommand(pduObj, 'submit_sm') || knownRecipients.has(pduObj.params.destination_addr)) { +@@ -274,21 +267,18 @@ describe('README: Server', () => { + + return true; + }, ++ onSms: () => { messages++; }, + }); + if (err) throw err; + + closeAfter(t, smpp); + +- let messages = 0; +- +- smpp.on('session', bound => bound.on('sms', () => { messages++; })); +- + const connected = await client(); + if (connected.err) throw connected.err; + +- closeAfter(t, connected.session); ++ closeAfter(t, connected.client); + +- const sent = await connected.session.sendSms({ ++ const sent = await connected.client.sendSms({ + from: '46701113311', + message: 'A submission long enough to need more than one segment. '.repeat(4), + to: '46700000000', +@@ -302,19 +292,19 @@ describe('README: Server', () => { + + describe('README: Errors', () => { + test('a refused connection reports err instead of throwing', async () => { +- const { err, session } = await client({ port: 1 }); ++ const { err, client: smpp } = await client({ port: 1 }); + + assert.ok(err instanceof Error); +- assert.equal(session, undefined); ++ assert.equal(smpp, undefined); + }); + + test('telling a refused PDU from a session that failed', async t => { + const smpp = await answeringServer(t); +- const bound = once(resolve => { smpp.on('session', resolve); }); +- const { err, session } = await client(); ++ const accepted = once(resolve => { smpp.on('session', resolve); }); ++ const { err, client: esme } = await client(); + if (err) throw err; + +- closeAfter(t, session); ++ closeAfter(t, esme); + + const warned: Record[] = []; + const log: SmppLog = { +@@ -325,7 +315,7 @@ describe('README: Errors', () => { + warn: (msg, metadata) => { warned.push({ msg, ...metadata }); }, + }; + +- session.on('sessionError', err => { ++ esme.on('sessionError', err => { + if (err instanceof PduRefusedError) { + log.warn('the peer sent a PDU that could not be read', { + cmdName: err.header.cmdName ?? err.header.cmdId, +@@ -338,8 +328,8 @@ describe('README: Errors', () => { + log.error('a session failure or lost traffic', { message: err.message }); + }); + +- const reported = once(resolve => { session.on('sessionError', resolve); }); +- const peer = await bound; ++ const reported = once(resolve => { esme.on('sessionError', resolve); }); ++ const peer = await accepted; + const { buffer } = objToPdu({ cmdName: 'enquire_link', seqNr: 5 }); + + assert.ok(buffer); +diff --git a/test/session-error.test.ts b/test/session-error.test.ts +index af9b3b9..198e6ad 100644 +--- a/test/session-error.test.ts ++++ b/test/session-error.test.ts +@@ -1,6 +1,6 @@ + import assert from 'node:assert/strict'; + import test, { describe } from 'node:test'; +-import type { PduHeader, Session } from '../src/index.ts'; ++import type { OnSms, PduHeader, Session } from '../src/index.ts'; + import type { TestContext } from 'node:test'; + import { PduRefusedError, client, server } from '../src/index.ts'; + import { bareTlvHeader, shortened, truncatedTlv, withUnknownCmdId } from './raw-pdus.ts'; +@@ -38,30 +38,30 @@ async function waitFor(condition: () => boolean, budget = 2000): Promise[0] = {}) { +- const { err, server: smpp } = await server({ port: 0 }); ++async function linked(t: TestContext, options: Parameters[0] = {}, onSms?: OnSms) { ++ const { err, server: smpp } = await server({ ...(onSms ? { onSms } : {}), port: 0 }); + + assert.equal(err, undefined); + assert.ok(smpp); + closeAfter(t, smpp); + + const accepted = once(resolve => { smpp.on('session', resolve); }); +- const { session } = await client({ port: smpp.port, ...options }); ++ const { client: esme } = await client({ port: smpp.port, ...options }); + +- assert.ok(session); +- closeAfter(t, session); ++ assert.ok(esme); ++ closeAfter(t, esme); + + const peer = await raceWithin(2000, accepted); + + assert.ok(peer, 'the server never accepted a session'); + +- return { peer, session }; ++ return { esme, peer }; + } + + describe('telling a refused PDU from a failed session', () => { + test('narrows a refusal to its class, command and reason, from the entry point alone', async t => { +- const { peer, session } = await linked(t); +- const failed = once(resolve => { session.on('sessionError', resolve); }); ++ const { esme, peer } = await linked(t); ++ const failed = once(resolve => { esme.on('sessionError', resolve); }); + + peer.sock.write(truncatedTlv({ cmdName: 'deliver_sm', params: receipt, seqNr: 5 })); + +@@ -77,10 +77,10 @@ describe('telling a refused PDU from a failed session', () => { + }); + + test('names which part of the PDU it could not read, one refusal at a time', async t => { +- const { peer, session } = await linked(t); ++ const { esme, peer } = await linked(t); + const seen: Error[] = []; + +- session.on('sessionError', err => { seen.push(err); }); ++ esme.on('sessionError', err => { seen.push(err); }); + + peer.sock.write(withUnknownCmdId({ cmdName: 'enquire_link', seqNr: 9 })); + peer.sock.write(shortened({ cmdName: 'deliver_sm', params: receipt, seqNr: 6 }, 3)); +@@ -94,24 +94,24 @@ describe('telling a refused PDU from a failed session', () => { + ); + }); + +- test('reports a listener that threw as an error that is no refusal', async t => { +- const { peer, session } = await linked(t, { responseTimeout: 200 }); ++ test('reports a handler that threw as an error that is no refusal', async t => { ++ const { esme, peer } = await linked(t, { responseTimeout: 200 }, () => { throw new Error('handler exploded'); }); + const failed = once(resolve => { peer.on('sessionError', resolve); }); + +- peer.on('sms', () => { throw new Error('listener exploded'); }); ++ const sent = await esme.sendSms({ from: '46701113311', message: 'blows the handler up', to: '46709771337' }); + +- await session.sendSms({ from: '46701113311', message: 'blows the listener up', to: '46709771337' }); ++ assert.match(sent.err?.message ?? '', /ESME_RTHROTTLED/, 'a message the handler failed on is refused'); + + const reported = await raceWithin(2000, failed); + +- assert.ok(reported, 'the listener that threw never reached the session'); ++ assert.ok(reported, 'the handler that threw never reached the session'); + assert.ok(!(reported instanceof PduRefusedError), 'a session failure is not a refused PDU'); +- assert.equal(reported.message, 'listener exploded'); ++ assert.equal(reported.message, 'handler exploded'); + }); + + test('reports a socket the peer reset as an error that is no refusal', async t => { +- const { peer, session } = await linked(t, { reconnect: false }); +- const failed = once(resolve => { session.on('sessionError', resolve); }); ++ const { esme, peer } = await linked(t, { reconnect: false }); ++ const failed = once(resolve => { esme.on('sessionError', resolve); }); + + peer.sock.resetAndDestroy(); + +diff --git a/test/session-extras.test.ts b/test/session-extras.test.ts +index 6b36279..cfafd76 100644 +--- a/test/session-extras.test.ts ++++ b/test/session-extras.test.ts +@@ -4,35 +4,37 @@ import test, { describe } from 'node:test'; + 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 { MessageDlr } from '../src/dlr-merger.ts'; + import type { MessageState } from '../src/defs/constants.ts'; +-import type { MessageDlr } from '../src/session.ts'; ++import type { OnSms } from '../src/session-options.ts'; + import type { PduObject, PduObjectInput } from '../src/pdu.ts'; + import type { Result } from '../src/result.ts'; + import type { SendSmsResult } from '../src/send-sms.ts'; ++import type { SmppClient } from '../src/client.ts'; + import type { SmppLog } from '../src/log.ts'; +-import type { Sms } from '../src/sms.ts'; ++import type { Sms, SmsDeps } from '../src/sms.ts'; + import type { SmppServer } from '../src/server.ts'; + import type { TestContext } from 'node:test'; +-import { HeldMessages } from '../src/held-messages.ts'; +-import { IncomingRequests, refusedSegmentStatus } from '../src/incoming-requests.ts'; ++import { ReconnectLoop } from '../src/reconnect-loop.ts'; ++import { RunningHandlers } from '../src/running-handlers.ts'; + import { UnansweredError } from '../src/unanswered-error.ts'; + import { createSms } from '../src/sms.ts'; +-import { LinkLife } from '../src/link-life.ts'; ++import { refusedSegmentStatus } from '../src/incoming-requests.ts'; + import { SendWindow } from '../src/send-window.ts'; + import { Reassembler, decodeSegments } from '../src/reassembly.ts'; + 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, standsInFor } from '../src/session-options.ts'; + import { client } from '../src/client.ts'; + import { closeAfter, closeListenerAfter } from './teardown.ts'; + import { concatOf } from '../src/concat.ts'; + import { consts } from '../src/defs/constants.ts'; ++import { defaults } from '../src/defaults.ts'; + import { errors } from '../src/defs/errors.ts'; + import { paramNumber, paramText } from '../src/defs/types.ts'; ++import { pduBytes } from './raw-pdus.ts'; + import { server } from '../src/server.ts'; + import { silentLog } from '../src/log.ts'; + import { splitMessage } from '../src/message.ts'; +@@ -58,11 +60,25 @@ async function connect( + ) { + const connected = await client({ port: smpp.port, ...options }); + +- if (connected.session) closeAfter(t, connected.session); ++ if (connected.client) closeAfter(t, connected.client); + + return connected; + } + ++/** A bound client, asserted, since nearly every test here starts from one. */ ++async function bound( ++ t: TestContext, ++ smpp: SmppServer, ++ options: Parameters[0] = {}, ++): Promise { ++ const { err, client: esme } = await connect(t, smpp, options); ++ ++ assert.equal(err, undefined); ++ assert.ok(esme); ++ ++ return esme; ++} ++ + /** An event that never fires would otherwise block until the CI job limit, asserting nothing. */ + function once(register: (resolve: (value: T) => void) => void): Promise { + return new Promise((resolve, reject) => { +@@ -81,6 +97,19 @@ function delay(ms: number): Promise { + return new Promise(resolve => { setTimeout(resolve, ms); }); + } + ++/** Polls until the condition holds; false means it never did within the budget. */ ++async function waitFor(condition: () => boolean, budget = 2000): Promise { ++ const deadline = Date.now() + budget; ++ ++ while (!condition()) { ++ if (Date.now() > deadline) return false; ++ ++ await delay(5); ++ } ++ ++ return true; ++} ++ + /** Undefined where the promise never settled, which is an assertion rather than a hung run. */ + function within(ms: number, promise: Promise): Promise { + return Promise.race([promise, delay(ms).then((): undefined => undefined)]); +@@ -95,20 +124,9 @@ function abortAfter( + t.after(async () => { + controller.abort(); + +- const { session } = await connecting; +- +- await session?.close({ signal: AbortSignal.abort() }); +- }); +-} ++ const { client: esme } = await connecting; + +-function incomingOn(session: Session, options: Partial = {}): IncomingRequests { +- 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') }), +- session, +- ...options, ++ await esme?.close({ signal: AbortSignal.abort() }); + }); + } + +@@ -139,6 +157,15 @@ function peerOf(smpp: SmppServer): Session { + return peer; + } + ++/** The client's bound session, asserted. */ ++function linkOf(esme: SmppClient): Session { ++ const session = esme.session; ++ ++ assert.ok(session, 'the client has no bound session'); ++ ++ return session; ++} ++ + async function sendReceipt(peer: Session, smsId: string, tlvSmsId = smsId): Promise { + const sent = await peer.send({ + cmdName: 'deliver_sm', +@@ -167,89 +194,93 @@ function latch(): Latch { + return { open: () => opener.open?.(), passed }; + } + +-describe('merged delivery reports', () => { +- // 0.4.0 allocated a longSmsDlrs store to do exactly this and then never used it. +- test('reports once on a whole multipart message', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); +- const { session } = await connect(t, smpp); +- +- assert.ok(session); +- +- const merged = once(resolve => { session.on('messageDlr', resolve); }); +- const perSegment: string[] = []; +- +- session.on('dlr', dlr => perSegment.push(dlr.smsId ?? '')); +- +- const [sms] = await Promise.all([ +- incoming.then(async received => { +- await received.sendResp(); +- +- return received; +- }), +- session.sendSms({ +- dlr: true, +- from: '46701113311', +- message: 'x'.repeat(400), +- to: '46709771337', +- }), +- ]); +- +- await sms.sendDlr(); +- +- const report = await merged; ++/** A handler that never returns: the message stays with the application for the test's life. */ ++function neverReturns(): Promise { ++ return new Promise(() => undefined); ++} + +- assert.equal(report.smsId, sms.smsId); +- assert.equal(report.segments.length, 3); +- assert.equal(report.statusMsg, 'DELIVERED'); +- assert.deepEqual(perSegment, [1, 2, 3].map(part => `${sms.smsId}-${String(part)}`)); ++/** A handler that hands the message to the test and keeps running until the test lets it go. */ ++function handedOver(): { onSms: OnSms; release: () => void; sms: Promise } { ++ const released = latch(); ++ const handed: { resolve?: (sms: Sms) => void } = {}; ++ const sms = once(resolve => { ++ handed.resolve = resolve; + }); + +- test('reports once, on the final receipts, when the peer reports en route first', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); +- const { session } = await connect(t, smpp); ++ return { ++ onSms: async received => { ++ handed.resolve?.(received); ++ await released.passed; ++ }, ++ release: released.open, ++ sms, ++ }; ++} ++ ++/** A message reaching a bare session as the socket would deliver it: through its data event. */ ++function arrives(session: Session, input: PduObjectInput): void { ++ session.sock.emit('data', pduBytes(input)); ++} + +- assert.ok(session); ++/** What sendResp() and sendDlr() need, answered by the test. */ ++function smsDeps(overrides: Partial = {}): SmsDeps { ++ return { ++ acceptsOptionalParams: () => true, ++ answer: () => Promise.resolve({}), ++ bindAllows: () => true, ++ request: () => Promise.resolve({ err: new Error('never sent') }), ++ ...overrides, ++ }; ++} + +- const merged = once(resolve => { session.on('messageDlr', resolve); }); ++describe('merged delivery reports', () => { ++ async function reported(t: TestContext, status: MessageState[]): Promise<{ esme: SmppClient; reports: Dlr[]; markers: number[]; merged: Promise; sms: Sms }> { ++ const handed = handedOver(); ++ const smpp = await startServer(t, { onSms: handed.onSms }); ++ const esme = await bound(t, smpp); ++ const merged = once(resolve => { esme.on('messageDlr', resolve); }); + const reports: Dlr[] = []; +- const markers: (number | undefined)[] = []; ++ const markers: number[] = []; + +- session.on('dlr', (dlr, pduObj) => { ++ esme.on('dlr', (dlr, pduObj) => { + reports.push(dlr); + markers.push(paramNumber(pduObj.params.esm_class, 0)); + }); + +- const [sms] = await Promise.all([ +- incoming.then(async received => { +- await received.sendResp(); ++ const sent = esme.sendSms({ dlr: true, from: '46701113311', message: 'x'.repeat(400), to: '46709771337' }); ++ const sms = await handed.sms; ++ ++ await sms.sendResp(); ++ handed.release(); ++ await sent; + +- return received; +- }), +- session.sendSms({ +- dlr: true, +- from: '46701113311', +- message: 'x'.repeat(400), +- to: '46709771337', +- }), +- ]); ++ for (const one of status) { ++ await sms.sendDlr(one); ++ } + +- await sms.sendDlr('ENROUTE'); +- await sms.sendDlr('DELIVERED'); ++ return { esme, markers, merged, reports, sms }; ++ } + ++ // 0.4.0 allocated a longSmsDlrs store to do exactly this and then never used it. ++ test('reports once on a whole multipart message', async t => { ++ const { merged, reports, sms } = await reported(t, ['DELIVERED']); + const report = await merged; + + assert.equal(report.smsId, sms.smsId); +- assert.equal(report.statusMsg, 'DELIVERED'); + assert.equal(report.segments.length, 3); ++ assert.equal(report.statusMsg, 'DELIVERED'); ++ assert.deepEqual(reports.map(one => one.smsId), [1, 2, 3].map(part => `${sms.smsId}-${String(part)}`)); ++ }); ++ ++ test('reports once, on the final receipts, when the peer reports en route first', async t => { ++ const { markers, merged, reports, sms } = await reported(t, ['ENROUTE', 'DELIVERED']); ++ const report = await merged; + const notification = consts.ESM_CLASS.INTERMEDIATE_DELIVERY; + const receipt = consts.ESM_CLASS.MC_DELIVERY_RECEIPT; + ++ assert.equal(report.smsId, sms.smsId); ++ assert.equal(report.statusMsg, 'DELIVERED'); ++ assert.equal(report.segments.length, 3); + assert.deepEqual(reports.map(one => one.intermediate), [true, true, true, false, false, false]); + assert.deepEqual( + markers, +@@ -264,32 +295,7 @@ describe('merged delivery reports', () => { + }); + + test('reports the worst status across the segments', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); +- const { session } = await connect(t, smpp); +- +- assert.ok(session); +- +- const merged = once(resolve => { session.on('messageDlr', resolve); }); +- +- const [sms] = await Promise.all([ +- incoming.then(async received => { +- await received.sendResp(); +- +- return received; +- }), +- session.sendSms({ +- dlr: true, +- from: '46701113311', +- message: 'x'.repeat(400), +- to: '46709771337', +- }), +- ]); +- +- await sms.sendDlr('UNDELIVERABLE'); +- ++ const { merged } = await reported(t, ['UNDELIVERABLE']); + const report = await merged; + + assert.equal(report.statusMsg, 'UNDELIVERABLE'); +@@ -378,18 +384,9 @@ describe('sendSms()', () => { + } + + test('reports a submit_sm the peer refused instead of an empty message id', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); +- const { session } = await connect(t, smpp); +- +- assert.ok(session); +- +- const [, sent] = await Promise.all([ +- incoming.then(received => received.sendResp({ status: 'ESME_RMSGQFUL' })), +- session.sendSms({ from: '46701113311', message: 'the queue is full', to: '46709771337' }), +- ]); ++ const smpp = await startServer(t, { onSms: () => ({ status: 'ESME_RMSGQFUL' }) }); ++ const esme = await bound(t, smpp); ++ const sent = await esme.sendSms({ from: '46701113311', message: 'the queue is full', to: '46709771337' }); + + assert.ok(sent.err instanceof Error); + assert.match(sent.err.message, /ESME_RMSGQFUL/); +@@ -471,83 +468,73 @@ describe('sendSms()', () => { + }); + + describe('reconnect', () => { +- test('re-binds after a drop with nothing asked for, since it is the default', async t => { ++ test('opens a new session after a drop with nothing asked for, since it is the default', async t => { + const smpp = await startServer(t); +- const { session } = await connect(t, smpp); +- +- assert.ok(session); +- +- const reconnected = once(resolve => { session.on('reconnected', () => { resolve(true); }); }); +- +- const dropped = session.sock; ++ const esme = await bound(t, smpp); ++ const reconnected = once(resolve => { esme.on('reconnected', () => { resolve(true); }); }); ++ const dropped = linkOf(esme); + + await peerOf(smpp).close(); + await reconnected; + +- assert.notEqual(session.sock, dropped, 'the session should be live on a fresh socket'); ++ assert.equal(dropped.closed, true, 'a session is one socket\'s life'); ++ assert.notEqual(linkOf(esme), dropped, 'the client is live on a fresh session'); + }); + + test('reports a drop it will retry as disconnected, keeping close for the end', async t => { + const smpp = await startServer(t); +- const { session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } }); +- +- assert.ok(session); +- ++ const esme = await bound(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } }); + const events: string[] = []; + +- session.on('close', () => { events.push('close'); }); +- session.on('disconnected', () => { events.push('disconnected'); }); ++ esme.on('close', () => { events.push('close'); }); ++ esme.on('disconnected', () => { events.push('disconnected'); }); + +- const reconnected = once(resolve => { session.on('reconnected', () => { resolve(true); }); }); ++ const reconnected = once(resolve => { esme.on('reconnected', () => { resolve(true); }); }); + + await peerOf(smpp).close(); + await reconnected; + + assert.deepEqual(events, ['disconnected'], 'a link the loop brings back is not the end'); + +- await session.close(); ++ await esme.close(); + + assert.deepEqual(events, ['disconnected', 'close']); + }); + +- test('emits close when the session ends while the link is still down', async t => { ++ test('emits close when the client ends while the link is still down', async t => { + const smpp = await startServer(t); +- const { session } = await connect(t, smpp); +- +- assert.ok(session); +- ++ const esme = await bound(t, smpp); + const events: string[] = []; + +- session.on('close', () => { events.push('close'); }); +- session.on('disconnected', () => { events.push('disconnected'); }); ++ esme.on('close', () => { events.push('close'); }); ++ esme.on('disconnected', () => { events.push('disconnected'); }); + +- const down = once(resolve => { session.on('disconnected', () => { resolve(true); }); }); ++ const down = once(resolve => { esme.on('disconnected', () => { resolve(true); }); }); + + await peerOf(smpp).close(); + await down; +- await session.close(); ++ ++ assert.equal(esme.session, undefined, 'no session while the link is down'); ++ await esme.close(); + + assert.deepEqual(events, ['disconnected', 'close']); + +- await session.close(); ++ await esme.close(); + + assert.deepEqual(events, ['disconnected', 'close'], 'closing twice is still one close'); + }); + +- test('re-binds after a stream it cannot read, rather than ending the session', async t => { ++ test('re-binds after a stream it cannot read, rather than ending the client', async t => { + const smpp = await startServer(t); +- const { session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } }); +- +- assert.ok(session); +- ++ const esme = await bound(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } }); + const events: string[] = []; + +- session.on('close', () => { events.push('close'); }); +- session.on('sessionError', err => { ++ esme.on('close', () => { events.push('close'); }); ++ esme.on('sessionError', err => { + events.push(err instanceof PduRefusedError ? 'refused' : 'sessionError'); + }); + +- const reconnected = once(resolve => { session.on('reconnected', () => { resolve(true); }); }); ++ const reconnected = once(resolve => { esme.on('reconnected', () => { resolve(true); }); }); + + peerOf(smpp).sock.write(unreadablePdu); + await reconnected; +@@ -584,15 +571,12 @@ describe('reconnect', () => { + verbose: noop, + warn: noop, + }; +- const { session } = await connect(t, smpp, { log, reconnect: false }); +- +- assert.ok(session); +- ++ const esme = await bound(t, smpp, { log, reconnect: false }); + let disconnects = 0; + +- session.on('disconnected', () => { disconnects++; }); ++ esme.on('disconnected', () => { disconnects++; }); + +- const closed = once(resolve => { session.on('close', () => { resolve(true); }); }); ++ const closed = once(resolve => { esme.on('close', () => { resolve(true); }); }); + + peerOf(smpp).sock.write(unreadablePdu); + await closed; +@@ -601,29 +585,12 @@ describe('reconnect', () => { + assert.ok(!infos.includes('reconnect - retrying')); + }); + +- test('re-binds after the connection drops, keeping the same session object', async t => { +- const smpp = await startServer(t); ++ test('re-binds after the connection drops, keeping the same client handle', async t => { ++ const smpp = await startServer(t, { onSms: sms => { messages.push(sms.message); } }); + const messages: string[] = []; +- +- // Registered up front so the session created by the reconnect is covered too. +- smpp.on('session', bound => { +- bound.on('sms', sms => { +- messages.push(sms.message); +- void sms.sendResp(); +- }); +- }); +- +- const { err, session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } }); +- +- assert.equal(err, undefined); +- assert.ok(session); +- +- const reconnected = once(resolve => { session.on('reconnected', () => { resolve(true); }); }); +- const halfPdu = once(resolve => { session.on('data', () => { resolve(true); }); }); +- const boundBefore = [session.boundAs, session.peerInterfaceVersion]; +- const whileDown = once(resolve => { +- session.on('disconnected', () => { resolve([session.boundAs, session.peerInterfaceVersion]); }); +- }); ++ const esme = await bound(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } }); ++ const reconnected = once(resolve => { esme.on('reconnected', () => { resolve(true); }); }); ++ const halfPdu = once(resolve => { linkOf(esme).on('data', () => { resolve(true); }); }); + + // A PDU header promising 32 octets and sending 8: the next link must not continue it. + peerOf(smpp).sock.write(Buffer.from([0, 0, 0, 32, 0, 0, 0, 4])); +@@ -636,12 +603,10 @@ describe('reconnect', () => { + + await reconnected; + +- // The loop rebinds as before, so the gap keeps answering bindAllows() for the bind to come. +- assert.deepEqual(await whileDown, boundBefore); +- assert.equal(session.boundAs, 'transceiver'); ++ assert.equal(linkOf(esme).boundAs, 'transceiver'); + +- // The session object survives the drop, so listeners stay attached and it is usable again. +- const sent = await session.sendSms({ ++ // The client handle survives the drop, so listeners stay attached and it is usable again. ++ const sent = await esme.sendSms({ + from: '46701113311', + message: 'after reconnect', + to: '46709771337', +@@ -652,36 +617,32 @@ describe('reconnect', () => { + }); + + test('merges the receipts of a multipart message across a drop', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', bound => bound.on('sms', sms => { +- resolve(sms); +- void sms.sendResp(); +- })); +- }); +- const { session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } }); +- +- assert.ok(session); +- +- const sent = await session.sendSms({ ++ const handed = handedOver(); ++ const smpp = await startServer(t, { onSms: handed.onSms }); ++ const esme = await bound(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } }); ++ const sending = esme.sendSms({ + dlr: true, + from: '46701113311', + message: 'x'.repeat(400), + to: '46709771337', + }); +- const sms = await incoming; ++ const sms = await handed.sms; ++ ++ handed.release(); ++ ++ const sent = await sending; + + assert.deepEqual(sent.smsIds, [1, 2, 3].map(part => `${sms.smsId}-${String(part)}`)); + + // Answered on the link that then drops, so an SMSC has no reason to ever send it again. + await sendReceipt(peerOf(smpp), `${sms.smsId}-1`); + +- const reconnected = once(resolve => { session.on('reconnected', () => { resolve(true); }); }); ++ const reconnected = once(resolve => { esme.on('reconnected', () => { resolve(true); }); }); + + await peerOf(smpp).close(); + await reconnected; + +- const merged = once(resolve => { session.on('messageDlr', resolve); }); ++ const merged = once(resolve => { esme.on('messageDlr', resolve); }); + + await sendReceipt(peerOf(smpp), `${sms.smsId}-2`); + await sendReceipt(peerOf(smpp), `${sms.smsId}-3`); +@@ -692,19 +653,20 @@ describe('reconnect', () => { + assert.equal(report.segments.length, 3); + }); + +- test('refuses to answer a message whose link went, held or already answered', async t => { ++ // A message belongs to the session it arrived on, and that session ended with its socket. ++ test('refuses to answer or report on a message whose session went', async t => { + const smpp = await startServer(t); +- const { session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } }); +- +- assert.ok(session); +- + const arrived: Sms[] = []; +- const both = once(resolve => { +- session.on('sms', sms => { ++ const both = latch(); ++ const esme = await bound(t, smpp, { ++ onSms: async sms => { + arrived.push(sms); + +- if (arrived.length === 2) resolve(true); +- }); ++ if (arrived.length === 2) both.open(); ++ ++ await neverReturns(); ++ }, ++ reconnect: { maxDelay: 100, minDelay: 20 }, + }); + + for (const text of ['answered before the drop', 'never answered']) { +@@ -718,7 +680,7 @@ describe('reconnect', () => { + }); + } + +- await both; ++ await both.passed; + + const [answered, held] = arrived; + +@@ -727,57 +689,49 @@ describe('reconnect', () => { + assert.equal(answered.message, 'answered before the drop'); + assert.equal((await answered.sendResp()).err, undefined); + +- const reconnected = once(resolve => { session.on('reconnected', () => { resolve(true); }); }); ++ const reconnected = once(resolve => { esme.on('reconnected', () => { resolve(true); }); }); + + await peerOf(smpp).close({ signal: AbortSignal.abort() }); + await reconnected; + + let taken = 0; + +- // A response is dispatched before `incomingPduObj`, so only the raw event sees one arrive. + peerOf(smpp).on('incomingPdu', () => { taken++; }); + +- assert.match((await answered.sendResp()).err?.message ?? '', /link this message arrived on is gone/); +- assert.match((await held.sendResp()).err?.message ?? '', /link this message arrived on is gone/); +- +- assert.equal((await held.sendDlr('DELIVERED')).err, undefined); +- assert.equal(taken, 1, 'a refused response reached the new link'); ++ assert.match((await answered.sendResp({ smsId: 'late' })).err?.message ?? '', /already answered/); ++ assert.match((await held.sendResp()).err?.message ?? '', /Socket is closed/); ++ assert.match((await answered.sendDlr('DELIVERED')).err?.message ?? '', /Session closed|Socket is closed|link went/); ++ assert.equal(held.session.closed, true); ++ assert.equal(taken, 0, 'nothing of it reached the new link'); + }); + +- test('drops a message whose link went while onRequest was still running', async t => { +- const session = new Session({ sock: new net.Socket() }); +- +- closeAfter(t, session); +- +- const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 100 }); +- const incoming = incomingOn(session, { link, onRequest: async () => { await delay(10); return false; } }); ++ test('drops a request whose socket closed while onRequest was still running', async t => { + let messages = 0; ++ const session = new Session({ ++ onRequest: async () => { ++ await delay(10); + +- session.on('sms', () => { messages++; }); +- +- const handled = incoming.handle(submitPdu(1)); +- +- link.drop(); ++ return false; ++ }, ++ onSms: () => { messages++; }, ++ sock: new net.Socket(), ++ }); + +- await handled; ++ closeAfter(t, session); ++ arrives(session, { cmdName: 'submit_sm', params: submitPdu(1).params, seqNr: 1 }); ++ session.sock.destroy(); ++ await delay(30); + + assert.equal(messages, 0); +- +- await incoming.handle(submitPdu(2)); +- +- assert.equal(messages, 1, 'the harness delivers a message whose link stayed'); + }); + + test('does not reconnect after an explicit close', async t => { + const smpp = await startServer(t); +- const { session } = await connect(t, smpp, { reconnect: { maxDelay: 50, minDelay: 10 } }); +- +- assert.ok(session); +- ++ const esme = await bound(t, smpp, { reconnect: { maxDelay: 50, minDelay: 10 } }); + let reconnects = 0; + +- session.on('reconnected', () => { reconnects++; }); +- await session.close(); ++ esme.on('reconnected', () => { reconnects++; }); ++ await esme.close(); + + await delay(150); + +@@ -830,10 +784,10 @@ describe('reconnect from the first bind', () => { + const port = await closedPort(); + const spy = logSpy(); + const started = Date.now(); +- const { err, session } = await client({ log: spy.log, port, reconnect: { maxDelay: 40, minDelay: 10 } }); ++ const { err, client: esme } = await client({ log: spy.log, port, reconnect: { maxDelay: 40, minDelay: 10 } }); + + assert.ok(err instanceof Error); +- assert.equal(session, undefined); ++ assert.equal(esme, undefined); + assert.ok(Date.now() - started < 1000, 'the default answers the caller rather than retrying'); + assert.ok(!spy.messages.includes('reconnect - retrying'), 'nothing may retry what the default gave up on'); + }); +@@ -858,15 +812,15 @@ describe('reconnect from the first bind', () => { + assert.ok(listening.server); + closeAfter(t, listening.server); + +- const { err, session } = await connecting; ++ const { err, client: esme } = await connecting; + + assert.equal(err, undefined); +- assert.ok(session); +- assert.equal(session.boundAs, 'transceiver'); ++ assert.ok(esme); ++ assert.equal(linkOf(esme).boundAs, 'transceiver'); + assert.ok(spy.delays.length >= 3, 'the SMSC was down for several attempts'); + assert.deepEqual(spy.delays.slice(0, 3), [10, 20, 40], 'each wait doubles, up to maxDelay'); + assert.ok( +- !spy.messages.includes('session - reconnected'), ++ !spy.messages.includes('client - reconnected'), + 'a first link is not a link coming back', + ); + }); +@@ -886,10 +840,10 @@ describe('reconnect from the first bind', () => { + + abortAfter(t, controller, connecting); + +- const { err, session } = await connecting; ++ const { err, client: esme } = await connecting; + + assert.equal(err, undefined); +- assert.ok(session); ++ assert.ok(esme); + assert.equal(binds, 3, 'the two refusals were retried, not reported'); + assert.deepEqual(spy.delays, [10, 20], 'a link the SMSC refused a bind on does not reset the backoff'); + }); +@@ -919,14 +873,14 @@ describe('reconnect from the first bind', () => { + + abortAfter(t, controller, connecting); + +- const { err, session } = await connecting; ++ const { err, client: esme } = await connecting; + + assert.equal(err, undefined); +- assert.ok(session); ++ assert.ok(esme); + assert.deepEqual(spy.delays, [10, 20], 'only the loop that owns the retry announces one'); + }); + +- test('hands back a session that reported nothing and reconnects like any other', async t => { ++ test('hands back a client that reported nothing and reconnects like any other', async t => { + const port = await closedPort(); + const controller = new AbortController(); + const connecting = client({ +@@ -944,29 +898,29 @@ describe('reconnect from the first bind', () => { + assert.ok(listening.server); + closeAfter(t, listening.server); + +- const { session } = await connecting; ++ const { client: esme } = await connecting; + +- assert.ok(session); ++ assert.ok(esme); + + const events: string[] = []; + +- session.on('close', () => { events.push('close'); }); +- session.on('disconnected', () => { events.push('disconnected'); }); ++ esme.on('close', () => { events.push('close'); }); ++ esme.on('disconnected', () => { events.push('disconnected'); }); + +- const reconnected = once(resolve => { session.on('reconnected', () => { resolve(true); }); }); ++ const reconnected = once(resolve => { esme.on('reconnected', () => { resolve(true); }); }); + + await peerOf(listening.server).close(); + await reconnected; + + assert.deepEqual(events, ['disconnected'], 'the attempts before the first link reported nothing'); + +- const closed = once(resolve => { session.on('close', () => { resolve(true); }); }); ++ const closed = once(resolve => { esme.on('close', () => { resolve(true); }); }); + + controller.abort(); + await closed; + + // Which is why a deadline for the wait may not be a signal that goes on arming afterwards. +- assert.deepEqual(events, ['disconnected', 'close'], 'the signal that bounded the wait ends the session'); ++ assert.deepEqual(events, ['disconnected', 'close'], 'the signal that bounded the wait ends the client'); + }); + + test('lets an abort out of the initial retries', async () => { +@@ -986,7 +940,7 @@ describe('reconnect from the first bind', () => { + assert.ok(settled, 'an aborted signal is the way out of a wait nothing else ends'); + assert.ok(settled.err instanceof Error); + assert.ok(settled.err.cause instanceof Error, 'an abort carries what the attempts kept failing with'); +- assert.equal(settled.session, undefined); ++ assert.equal(settled.client, undefined); + }); + + test('holds the process open between the initial attempts', async t => { +@@ -1025,7 +979,7 @@ describe('reconnect from the first bind', () => { + const refused = await client(misspelled); + + assert.ok(refused.err instanceof Error); +- assert.equal(refused.session, undefined); ++ assert.equal(refused.client, undefined); + assert.match(checkSessionOptions({ reconnect: { fromStart: 'yes' } }).err?.message ?? '', /fromStart/); + assert.equal(checkSessionOptions({ reconnect: { fromStart: true, minDelay: 10 } }).err, undefined); + }); +@@ -1065,7 +1019,7 @@ describe('connectTimeout', () => { + new RegExp(`Timed out completing the TLS handshake with 127\\.0\\.0\\.1:${String(port)} after 150 ms; raise connectTimeout`), + 'a firewall and a peer that accepts then stalls need different answers, and whoever reads this has never heard of the option', + ); +- assert.equal(settled.session, undefined); ++ assert.equal(settled.client, undefined); + }); + + // Waits the default out for real: node:test mock timers land in Node 20.4, and the floor is 18. +@@ -1099,15 +1053,14 @@ describe('connectTimeout', () => { + + test('disarms on the connect that completed, rather than on the socket that follows it', async t => { + const smpp = await startServer(t); +- const { session } = await connect(t, smpp, { connectTimeout: 200 }); ++ const esme = await bound(t, smpp, { connectTimeout: 200 }); + +- assert.ok(session); + await delay(300); + +- const probe = await session.send({ cmdName: 'enquire_link' }); ++ const probe = await esme.send({ cmdName: 'enquire_link' }); + + assert.equal(probe.err, undefined); +- assert.equal(session.sock.destroyed, false); ++ assert.equal(linkOf(esme).sock.destroyed, false); + }); + + test('refuses a connect timeout that would turn itself off', async () => { +@@ -1137,7 +1090,7 @@ describe('connectTimeout', () => { + + assert.ok(refused.err instanceof Error); + assert.match(refused.err.message, /false waits/, 'the socket may not be opened before the option is refused'); +- assert.equal(refused.session, undefined); ++ assert.equal(refused.client, undefined); + + const off = await client({ connectTimeout: false, port: 1 }); + +@@ -1148,37 +1101,32 @@ describe('connectTimeout', () => { + + describe('sends across a reconnect', () => { + /** Answers every message after the first, which is left to hold the send window open. */ +- function answerAfterTheFirst(smpp: SmppServer, arrived: string[]): Latch { ++ function answerAfterTheFirst(arrived: string[]): { first: Latch; onSms: OnSms } { + const first = latch(); + +- smpp.on('session', peer => { +- peer.on('sms', async sms => { ++ return { ++ first, ++ onSms: async sms => { + arrived.push(sms.message); + +- if (arrived.length === 1) first.open(); +- else await sms.sendResp(); +- }); +- }); +- +- return first; ++ if (arrived.length === 1) { ++ first.open(); ++ await neverReturns(); ++ } ++ }, ++ }; + } + + test('holds a send issued while the link is down and puts it on the new link', async t => { +- const smpp = await startServer(t); + const arrived: string[] = []; +- +- smpp.on('session', peer => { peer.on('sms', async sms => { arrived.push(sms.message); await sms.sendResp(); }); }); +- +- const { session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } }); +- +- assert.ok(session); +- +- const down = once(resolve => { session.on('disconnected', () => { resolve(true); }); }); ++ const smpp = await startServer(t, { onSms: sms => { arrived.push(sms.message); } }); ++ const esme = await bound(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } }); ++ const down = once(resolve => { esme.on('disconnected', () => { resolve(true); }); }); + + await peerOf(smpp).close(); + await down; + +- const sent = await session.sendSms({ from: '46701113311', message: 'held', to: '46709771337' }); ++ const sent = await esme.sendSms({ from: '46701113311', message: 'held', to: '46709771337' }); + + assert.equal(sent.err, undefined); + assert.equal(sent.smsIds.length, 1); +@@ -1187,21 +1135,18 @@ describe('sends across a reconnect', () => { + }); + + test('puts a segment still queued behind a full window on the new link', async t => { +- const smpp = await startServer(t); + const arrived: string[] = []; +- const first = answerAfterTheFirst(smpp, arrived); +- const { session } = await connect(t, smpp, { ++ const { first, onSms } = answerAfterTheFirst(arrived); ++ const smpp = await startServer(t, { onSms }); ++ const esme = await bound(t, smpp, { + maxOutstanding: 1, + reconnect: { maxDelay: 100, minDelay: 20 }, + }); +- +- assert.ok(session); +- +- const holding = session.sendSms({ from: '46701113311', message: 'first', to: '46709771337' }); ++ const holding = esme.sendSms({ from: '46701113311', message: 'first', to: '46709771337' }); + + await first.passed; + +- const queued = session.sendSms({ from: '46701113311', message: 'second', to: '46709771337' }); ++ const queued = esme.sendSms({ from: '46701113311', message: 'second', to: '46709771337' }); + + peerOf(smpp).sock.destroy(); + +@@ -1228,20 +1173,16 @@ describe('sends across a reconnect', () => { + + return true; + }, ++ onSms: () => undefined, + }); +- +- smpp.on('session', peer => { peer.on('sms', async sms => { await sms.sendResp(); }); }); +- +- const { session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } }); +- +- assert.ok(session); ++ const esme = await bound(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } }); + + peerOf(smpp).sock.destroy(); + +- // The fresh socket is attached and the bind is in flight, so the link exists but carries nothing. ++ // The fresh socket is open and the bind is in flight, so the link exists but carries nothing. + await binding.passed; + +- const sending = session.sendSms({ from: '46701113311', message: 'mid-bind', to: '46709771337' }); ++ const sending = esme.sendSms({ from: '46701113311', message: 'mid-bind', to: '46709771337' }); + let settled = false; + + void sending.then(() => { settled = true; }); +@@ -1258,34 +1199,25 @@ describe('sends across a reconnect', () => { + }); + + test('counts a segment the peer never answered in time as unanswered', async t => { +- const smpp = await startServer(t); +- +- smpp.on('session', peer => { peer.on('sms', () => undefined); }); +- +- const { session } = await connect(t, smpp, { responseTimeout: 200 }); +- +- assert.ok(session); +- +- const sent = await session.sendSms({ from: '46701113311', message: 'no answer', to: '46709771337' }); ++ const smpp = await startServer(t, { onSms: neverReturns }); ++ const esme = await bound(t, smpp, { responseTimeout: 200 }); ++ const sent = await esme.sendSms({ from: '46701113311', message: 'no answer', to: '46709771337' }); + + assert.match(sent.err?.message ?? '', /may have accepted/); + assert.equal(sent.unanswered, 1, 'a slow SMSC may still have taken it'); + }); + + test('counts a segment aborted after it went out as unanswered', async t => { +- const smpp = await startServer(t); +- const arrived = once(resolve => { smpp.on('session', peer => peer.on('sms', resolve)); }); +- const { session } = await connect(t, smpp); +- +- assert.ok(session); +- ++ const handed = handedOver(); ++ const smpp = await startServer(t, { onSms: handed.onSms }); ++ const esme = await bound(t, smpp); + const controller = new AbortController(); +- const sending = session.sendSms( ++ const sending = esme.sendSms( + { from: '46701113311', message: 'aborted mid-flight', to: '46709771337' }, + { signal: controller.signal }, + ); + +- await arrived; ++ await handed.sms; + controller.abort(); + + const sent = await sending; +@@ -1295,15 +1227,12 @@ describe('sends across a reconnect', () => { + }); + + test('reports a segment the link dropped under as unanswered, not as never sent', async t => { +- const smpp = await startServer(t); +- const arrived = once(resolve => { smpp.on('session', peer => peer.on('sms', resolve)); }); +- const { session } = await connect(t, smpp, { reconnect: false }); +- +- assert.ok(session); ++ const handed = handedOver(); ++ const smpp = await startServer(t, { onSms: handed.onSms }); ++ const esme = await bound(t, smpp, { reconnect: false }); ++ const sending = esme.sendSms({ from: '46701113311', message: 'in flight', to: '46709771337' }); + +- const sending = session.sendSms({ from: '46701113311', message: 'in flight', to: '46709771337' }); +- +- await arrived; ++ await handed.sms; + peerOf(smpp).sock.destroy(); + + const sent = await sending; +@@ -1315,38 +1244,32 @@ describe('sends across a reconnect', () => { + + test('gives up a held send after responseTimeout, with nothing put on the wire', async t => { + const smpp = await startServer(t); +- const { session } = await connect(t, smpp, { ++ const esme = await bound(t, smpp, { + reconnect: { maxDelay: 10_000, minDelay: 10_000 }, + responseTimeout: 200, + }); +- +- assert.ok(session); +- +- const down = once(resolve => { session.on('disconnected', () => { resolve(true); }); }); ++ const down = once(resolve => { esme.on('disconnected', () => { resolve(true); }); }); + + await peerOf(smpp).close(); + await down; + +- const sent = await session.sendSms({ from: '46701113311', message: 'held', to: '46709771337' }); ++ const sent = await esme.sendSms({ from: '46701113311', message: 'held', to: '46709771337' }); + + assert.match(sent.err?.message ?? '', /did not come back/); + assert.equal(sent.unanswered, 0, 'nothing reached the peer, so the message can be sent again'); + }); + +- test('fails a held send when the session closes rather than leaving it waiting', async t => { ++ test('fails a held send when the client closes rather than leaving it waiting', async t => { + const smpp = await startServer(t); +- const { session } = await connect(t, smpp, { ++ const esme = await bound(t, smpp, { + reconnect: { maxDelay: 10_000, minDelay: 10_000 }, + }); +- +- assert.ok(session); +- +- const down = once(resolve => { session.on('disconnected', () => { resolve(true); }); }); ++ const down = once(resolve => { esme.on('disconnected', () => { resolve(true); }); }); + + await peerOf(smpp).close(); + await down; + +- const sending = session.sendSms({ from: '46701113311', message: 'held', to: '46709771337' }); ++ const sending = esme.sendSms({ from: '46701113311', message: 'held', to: '46709771337' }); + let settled = false; + + void sending.then(() => { settled = true; }); +@@ -1354,7 +1277,7 @@ describe('sends across a reconnect', () => { + + assert.equal(settled, false, 'the send waits for a link rather than failing on the spot'); + +- await session.close(); ++ await esme.close(); + + const sent = await sending; + +@@ -1364,19 +1287,16 @@ describe('sends across a reconnect', () => { + + test('aborts a held send instead of making it wait out the link', async t => { + const smpp = await startServer(t); +- const { session } = await connect(t, smpp, { ++ const esme = await bound(t, smpp, { + reconnect: { maxDelay: 10_000, minDelay: 10_000 }, + }); +- +- assert.ok(session); +- +- const down = once(resolve => { session.on('disconnected', () => { resolve(true); }); }); ++ const down = once(resolve => { esme.on('disconnected', () => { resolve(true); }); }); + + await peerOf(smpp).close(); + await down; + + const controller = new AbortController(); +- const sending = session.sendSms( ++ const sending = esme.sendSms( + { from: '46701113311', message: 'held', to: '46709771337' }, + { signal: controller.signal }, + ); +@@ -1389,106 +1309,89 @@ describe('sends across a reconnect', () => { + assert.equal(sent.unanswered, 0); + }); + +- test('refuses a send outright once the session is over', async t => { ++ test('refuses a send outright once the client is over', async t => { + const smpp = await startServer(t); +- const { session } = await connect(t, smpp, { reconnect: false }); ++ const esme = await bound(t, smpp, { reconnect: false }); + +- assert.ok(session); +- await session.close(); ++ await esme.close(); + +- const sent = await session.sendSms({ from: '46701113311', message: 'too late', to: '46709771337' }); ++ const sent = await esme.sendSms({ from: '46701113311', message: 'too late', to: '46709771337' }); + + assert.match(sent.err?.message ?? '', /closed/); + assert.equal(sent.unanswered, 0); + }); + }); + +-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); +- +- link.drop(); +- now = 101; +- +- const held = await waitForLink(); +- +- assert.match(held.err?.message ?? '', /did not come back in time/); +- }); ++describe('ReconnectLoop', () => { ++ function loop(options: Partial[0]> = {}): ReconnectLoop { ++ return new ReconnectLoop({ ++ log: silentLog, ++ onDown: () => undefined, ++ onUp: () => undefined, ++ open: () => Promise.resolve({ err: new Error('nothing to open') }), ++ ...options, ++ }); ++ } + +- test('holds on a timer that keeps the process alive', async () => { +- const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 10_000 }); ++ test('holds on a timer that keeps the process alive', async t => { ++ const links = loop(); + const timers = (): number => process.getActiveResourcesInfo().filter(name => name === 'Timeout').length; +- +- link.drop(); +- + const before = timers(); +- const held = link.hold(undefined)(); ++ const held = links.bound(10_000, undefined); + + assert.equal(timers(), before + 1, 'an unref\'d timer is not counted here, which is the point'); + +- link.open(); ++ const session = new Session({ sock: new net.Socket() }); ++ ++ closeAfter(t, session); ++ links.adopt(session); ++ ++ const link = await held; + +- assert.deepEqual(await held, {}); ++ assert.equal(link.session, session); ++ assert.equal(links.current(), session); + }); + + // addEventListener never fires for a signal that already aborted, so it would wait out the timeout. +- test('gives up at once on a signal that was already aborted', async () => { +- const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 100 }); ++ test('gives up at once on a signal that was already aborted, and on a deadline that passed', async () => { ++ const links = loop(); + +- link.drop(); ++ assert.match((await links.bound(100, AbortSignal.abort())).err?.message ?? '', /Aborted while waiting for a link/); + +- const held = await link.hold(AbortSignal.abort())(); ++ const expired = await links.bound(1, undefined); + +- assert.match(held.err?.message ?? '', /Aborted while waiting for a link/); ++ assert.match(expired.err?.message ?? '', /did not come back in time/); + }); + +- test('awaits the next link only while down with one on its way', () => { +- const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 100 }); +- +- assert.equal(link.awaitsNextLink(), false, 'up'); +- link.drop(); +- assert.equal(link.awaitsNextLink(), true, 'down, returning'); +- link.attach(); +- assert.equal(link.awaitsNextLink(), true, 'attached, not yet bound'); +- link.open(); +- assert.equal(link.awaitsNextLink(), false, 'reopened'); +- link.drop(); +- link.stop(); +- assert.equal(link.awaitsNextLink(), false, 'down, stopped'); +- assert.match(link.refusal()?.message ?? '', /closed/, 'stopped while down'); +- link.end(); +- assert.equal(link.awaitsNextLink(), false, 'ended'); +- }); ++ test('releases a held request with the reason once it stops', async () => { ++ const links = loop(); ++ const held = links.bound(0, undefined); + +- test('drops an attached link once, counts each drop, and names the event it warrants', () => { +- const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 100 }); +- const generation = link.generation(); ++ links.stop(); + +- assert.equal(link.drop(), 'disconnected'); +- assert.equal(link.drop(), undefined, '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.equal(link.generation(), generation + 2); +- assert.equal(new LinkLife({ log: silentLog, reconnects: false, timeout: 100 }).drop(), 'close'); ++ assert.match((await held).err?.message ?? '', /Session is closed/); ++ assert.match((await links.bound(0, undefined)).err?.message ?? '', /Session is closed/); + }); + +- test('releases a held request with the reason once the link ends', async () => { +- const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 0 }); ++ test('lets go of a session that ended, and of none other', async t => { ++ const events: string[] = []; ++ const links = loop({ onDown: retrying => { events.push(retrying ? 'down' : 'over'); } }); ++ const first = new Session({ sock: new net.Socket() }); ++ const second = new Session({ sock: new net.Socket() }); + +- link.drop(); ++ closeAfter(t, first); ++ closeAfter(t, second); ++ links.adopt(first); ++ links.stop(); ++ links.adopt(second); ++ await first.close(); + +- const held = link.hold(undefined)(); ++ assert.equal(links.current(), second, 'a session that is not the current one ending changes nothing'); + +- link.end(); ++ await second.close(); + +- assert.match((await held).err?.message ?? '', /Session is closed/); +- link.attach(); +- assert.equal(link.isAttached(), false, 'ended is final'); +- assert.equal(link.end(), false); ++ assert.equal(links.current(), undefined); ++ assert.deepEqual(events, ['over']); + }); + }); + +@@ -1534,206 +1437,192 @@ describe('SendWindow', () => { + assert.match(refused.err?.message ?? '', /Aborted while waiting for a send window slot/); + assert.equal(window.unfinished(), 1); + }); +-}); + +-// Goal 4: an application that answers nothing must not grow this for the life of the link. +-describe('held message bounds', () => { +- function message(seqNr: number): PduObject[] { +- return [submitPdu(seqNr)]; +- } +- +- function offer(held: HeldMessages, seqNr: number): MessageHold { +- const hold = held.offer(message(seqNr)); ++ test('settles everything queued with the reason on close()', async () => { ++ const window = new SendWindow({ limit: 1, log: silentLog }); + +- assert.ok(hold); ++ await window.acquire(undefined); + +- return hold; +- } ++ const queued = window.acquire(undefined); + +- /** Offers to a session with a listener, so an offer is held rather than released as untaken. */ +- function heldOn( +- t: TestContext, +- options: Pick, +- ): HeldMessages { +- const session = new Session({ sock: new net.Socket() }); ++ window.close(new Error('the link went')); + +- closeAfter(t, session); +- session.on('sms', () => undefined); ++ assert.match((await queued).err?.message ?? '', /the link went/); ++ assert.equal(window.unfinished(), 1, 'only the slot on the wire is still owed'); ++ }); ++}); + +- return new HeldMessages({ +- ...options, +- link: new LinkLife({ log: silentLog, reconnects: false, timeout: 100 }), +- log: silentLog, +- sendPastDrain: () => Promise.resolve({ err: new Error('never sent') }), +- session, +- }); +- } ++// Goal 4: an application that never returns from its handler must not grow this for the life of the link. ++describe('running handler bounds', () => { ++ test('is full at its count, until a handler returns', () => { ++ const running = new RunningHandlers({ log: silentLog, max: 2, maxOctets: 1_000_000, timeout: 10_000 }); ++ const first = running.start(10); + +- test('is full at its count, and a re-used sequence number replaces rather than adding', t => { +- const held = heldOn(t, { max: 2, maxOctets: 1_000_000, timeout: 10_000 }); +- const first = offer(held, 1); +- const replaced = offer(held, 2); ++ running.start(10); + +- offer(held, 2); ++ assert.equal(running.size, 2); ++ assert.equal(running.octets, 20); ++ assert.equal(running.full(), true); + +- 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); ++ first(); + +- held.clear(); ++ assert.equal(running.full(), false); ++ running.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 handler leaves by any way out', async () => { + let now = 0; +- const held = heldOn(t, { max: 10, maxOctets: 2000, now: () => now, timeout: 10_000 }); +- const answered = offer(held, 1); ++ const warned: string[] = []; ++ const running = new RunningHandlers({ ++ log: { ...silentLog, warn: msg => { warned.push(msg); } }, ++ max: 10, ++ maxOctets: 2000, ++ now: () => now, ++ timeout: 60, ++ }); ++ const done = running.start(1000); ++ ++ assert.equal(running.full(), false); ++ running.start(1000); ++ assert.equal(running.full(), true); + +- assert.equal(held.full(), false); +- offer(held, 2); +- assert.equal(held.full(), true); ++ done(); ++ assert.equal(running.full(), false, 'after a return'); + +- answered.release(); +- assert.equal(held.full(), false, 'after a release'); +- offer(held, 3); ++ const waiting = running.idle(1000, undefined); + +- now = 20_000; +- held.sweep(); +- now = 0; +- assert.equal(held.full(), false, 'after a sweep'); +- offer(held, 4); +- offer(held, 5); ++ now = 61; + +- held.clear(); +- assert.equal(held.full(), false, 'after a clear'); ++ assert.equal(await waiting, 0, 'a handler past its timeout is no longer waited for'); ++ assert.equal(running.full(), false, 'after an expiry'); ++ assert.deepEqual(warned, ['runningHandlers - giving up on a handler that never returned']); + }); + +- // Dropping one the application still holds frees nothing, and the drain stops waiting for it. ++ // Refusing leaves the message with the peer, which sends it again once handlers return. + test('refuses what arrives past the bound with a status that asks the peer to retry', async t => { +- const session = new Session({ sock: new net.Socket() }); +- +- closeAfter(t, session); +- + const warnings: string[] = []; +- const incoming = incomingOn(session, { log: { ...silentLog, warn: message => { warnings.push(message); } } }); + const answers: (ErrorName | undefined)[] = []; + const received: Sms[] = []; ++ const session = new Session({ ++ log: { ...silentLog, warn: message => { warnings.push(message); } }, ++ onSms: sms => { ++ received.push(sms); ++ ++ return neverReturns(); ++ }, ++ sock: new net.Socket(), ++ }); + ++ closeAfter(t, session); + session.sendReturn = (_pdu, status) => { + answers.push(status); + + return Promise.resolve({}); + }; +- session.on('sms', sms => { received.push(sms); }); + +- for (let seqNr = 1; seqNr <= defaults.maxHeldMessages; seqNr++) { +- await incoming.handle(submitPdu(seqNr)); ++ for (let seqNr = 1; seqNr <= defaults.maxRunningHandlers; seqNr++) { ++ arrives(session, { cmdName: 'submit_sm', params: submitPdu(seqNr).params, seqNr }); + } + +- assert.equal(received.length, defaults.maxHeldMessages); ++ assert.ok(await waitFor(() => received.length === defaults.maxRunningHandlers)); + +- await incoming.handle(submitPdu(defaults.maxHeldMessages + 1)); +- await incoming.handle(segment(7, 1, 2)); ++ arrives(session, { cmdName: 'submit_sm', params: submitPdu(defaults.maxRunningHandlers + 1).params, seqNr: defaults.maxRunningHandlers + 1 }); ++ arrives(session, segmentInput(7, 1, 2)); + +- assert.equal(received.length, defaults.maxHeldMessages); ++ assert.ok(await waitFor(() => answers.length === 2)); ++ assert.equal(received.length, defaults.maxRunningHandlers); + assert.deepEqual(answers, ['ESME_RTHROTTLED', 'ESME_RTHROTTLED']); + assert.equal(warnings.length, 1, 'reaching the bound warns once, not per refusal'); + +- // The refused first segment joined no group, so the second one is taken and completes nothing. ++ // Answering does not free the count: the handler is still running. + await received[0]?.sendResp(); +- await new Promise(resolve => { setImmediate(resolve); }); +- await incoming.handle(segment(7, 2, 2)); +- +- assert.equal(answers.at(-1), 'ESME_ROK'); +- assert.equal(received.length, defaults.maxHeldMessages); +- +- // A peer keeping its window full crosses the bound on every answer, and that is still one warning. +- await received[1]?.sendResp(); +- await new Promise(resolve => { setImmediate(resolve); }); +- await incoming.handle(submitPdu(defaults.maxHeldMessages + 2)); +- await incoming.handle(submitPdu(defaults.maxHeldMessages + 3)); +- await incoming.handle(submitPdu(defaults.maxHeldMessages + 4)); ++ arrives(session, segmentInput(7, 2, 2)); + ++ assert.ok(await waitFor(() => answers.length === 4)); + assert.equal(answers.at(-1), 'ESME_RTHROTTLED'); + assert.equal(warnings.length, 1); +- incoming.clear(); + }); + + test('holds a message detached from the chunk it was read from', async t => { +- const session = new Session({ sock: new net.Socket() }); ++ let received: Sms | undefined; ++ const session = new Session({ onSms: sms => { received = sms; }, sock: new net.Socket() }); + + closeAfter(t, session); + +- const incoming = incomingOn(session); + const chunk = Buffer.alloc(64 * 1024); + const carried = submitPdu(1); +- let received: Sms | undefined; + +- session.on('sms', sms => { received = sms; }); +- await incoming.handle({ ...carried, params: { ...carried.params, short_message: chunk.subarray(16, 20) } }); ++ pduBytes({ cmdName: 'submit_sm', params: { ...carried.params, short_message: 'held' }, seqNr: 1 }).copy(chunk); ++ session.sock.emit('data', chunk.subarray(0, 60)); ++ ++ assert.ok(await waitFor(() => received !== undefined)); + +- const retained = received?.pduObjs[0]?.params.short_message; ++ const retained = received?.pduObjs[0]?.shortMessageOctets; + + assert.ok(Buffer.isBuffer(retained)); + assert.notEqual(retained.buffer, chunk.buffer); +- incoming.clear(); + }); ++}); + +- test('gives up on a message the application never answers', t => { +- let now = 0; +- const held = heldOn(t, { max: 10, maxOctets: 1_000_000, now: () => now, timeout: 60 }); +- +- offer(held, 1); +- now = 61; ++describe('sendResp()', () => { ++ // A response the wire never carried leaves the peer owed one, so nothing may count it answered. ++ test('does not count a response that never reached the wire as an answer', async t => { ++ const session = new Session({ sock: new net.Socket() }); + +- // The next message sweeps the one that expired, so only the new one is still waited for. +- offer(held, 2); ++ closeAfter(t, session); + +- assert.equal(held.size, 1); ++ let call = 0; ++ const sms = createSms({ pduObjs: [submitPdu(1)], session }, smsDeps({ ++ answer: () => Promise.resolve(++call === 1 ? { err: new Error('Socket is closed') } : {}), ++ })); + +- held.clear(); ++ assert.match((await sms.sendResp({ smsId: 'lost' })).err?.message ?? '', /Socket is closed/); ++ assert.notEqual(sms.smsId, 'lost', 'a failed answer leaves the id as it was'); ++ assert.deepEqual(await sms.sendResp({ smsId: 'carried' }), {}); ++ assert.equal(sms.smsId, 'carried'); + }); + +- // Without this the drain sits out its whole budget before returning what a sweep already settled. +- test('wakes a waiting drain when the last message expires', async t => { +- let now = 0; +- const held = heldOn(t, { max: 10, maxOctets: 1_000_000, now: () => now, timeout: 60 }); ++ test('answers once: a later id or refusal is an error, a bare call a no-op', async t => { ++ const session = new Session({ sock: new net.Socket() }); + +- offer(held, 1); ++ closeAfter(t, session); + +- const waiting = held.idle(1000, undefined); ++ const answers: ErrorName[] = []; ++ const sms = createSms({ pduObjs: [submitPdu(1)], session }, smsDeps({ ++ answer: (_pdu, status) => { ++ answers.push(status); + +- now = 61; +- held.sweep(); ++ return Promise.resolve({}); ++ }, ++ })); + +- assert.equal(await waiting, 0); ++ assert.deepEqual(await sms.sendResp(), {}); ++ assert.deepEqual(await sms.sendResp(), {}); ++ assert.match((await sms.sendResp({ smsId: 'other' })).err?.message ?? '', /already answered/); ++ assert.match((await sms.sendResp({ status: 'ESME_RMSGQFUL' })).err?.message ?? '', /nothing left to refuse/); ++ assert.deepEqual(answers, ['ESME_ROK']); + }); +-}); + +-describe('sendResp()', () => { +- // A response the wire never carried leaves the peer owed one, so nothing may count it answered. +- test('does not count a response that never reached the wire as an answer', async t => { ++ // A receipt names a message the peer had accepted, so nothing may report on one before its answer. ++ test('refuses sendDlr() until the message is answered', async t => { + const session = new Session({ sock: new net.Socket() }); + + closeAfter(t, session); + +- let answered = 0; +- +- session.sendReturn = () => Promise.resolve({ err: new Error('Socket is closed') }); ++ const requests: string[] = []; ++ const sms = createSms({ pduObjs: [submitPdu(1)], session }, smsDeps({ ++ request: input => { ++ requests.push(input.cmdName); + +- const sms = createSms({ +- pduObjs: [submitPdu(1)], +- session, +- }, { +- answered: () => { answered++; }, +- lostLink: () => false, +- send: () => Promise.resolve({ err: new Error('never sent') }), +- }); ++ return Promise.resolve({ pduObj: submitPdu(1) }); ++ }, ++ })); + +- assert.match((await sms.sendResp()).err?.message ?? '', /Socket is closed/); +- assert.equal(answered, 0); ++ assert.match((await sms.sendDlr()).err?.message ?? '', /Answer the message before reporting on it/); ++ assert.deepEqual(requests, []); ++ await sms.sendResp(); ++ assert.equal((await sms.sendDlr()).err, undefined); ++ assert.deepEqual(requests, ['deliver_sm']); + }); + }); + +@@ -1746,12 +1635,11 @@ describe('sendDlr()', () => { + + let call = 0; + const sms = createSms({ ++ answeredAs: '0199e0eb-4c11-7a02-9f31-2b6d80c4e517', + pduObjs: [submitPdu(1), submitPdu(2), submitPdu(3)], + session, +- }, { +- answered: () => undefined, +- lostLink: () => false, +- send: () => { ++ }, smsDeps({ ++ request: () => { + call++; + + if (call === 1) return Promise.resolve({ pduObj: submitPdu(1, 'ESME_RX_T_APPN') }); +@@ -1762,7 +1650,7 @@ describe('sendDlr()', () => { + + return Promise.resolve({ pduObj: submitPdu(3) }); + }, +- }); ++ })); + const report = await sms.sendDlr('DELIVERED'); + + assert.ok(report.err instanceof Error); +@@ -1772,11 +1660,16 @@ describe('sendDlr()', () => { + }); + }); + +-function segment(reference: number, part: number, total: number, width: 8 | 16 = 8): PduObject { ++function segmentBody(reference: number, part: number, total: number, width: 8 | 16 = 8): Buffer { + const udh = width === 8 + ? Buffer.from([0x05, 0x00, 0x03, reference, total, part]) + : Buffer.from([0x06, 0x08, 0x04, reference >>> 8, reference & 0xff, total, part]); +- const body = Buffer.concat([udh, Buffer.from('fragment')]); ++ ++ return Buffer.concat([udh, Buffer.from('fragment')]); ++} ++ ++function segment(reference: number, part: number, total: number, width: 8 | 16 = 8): PduObject { ++ const body = segmentBody(reference, part, total, width); + + return { + cmdId: 0x00000004, +@@ -1797,6 +1690,21 @@ function segment(reference: number, part: number, total: number, width: 8 | 16 = + }; + } + ++/** The same segment as the octets a peer writes. */ ++function segmentInput(reference: number, part: number, total: number): PduObjectInput { ++ return { ++ cmdName: 'submit_sm', ++ params: { ++ data_coding: 0, ++ destination_addr: '46709771337', ++ esm_class: 0x40, ++ short_message: segmentBody(reference, part, total), ++ source_addr: '46701113311', ++ }, ++ seqNr: 1000 + part, ++ }; ++} ++ + /** The same segment with its body where SMPP 3.4 5.3.2.32 allows it instead. */ + function payloadSegment(reference: number, part: number, total: number): PduObject { + const carried = segment(reference, part, total); +@@ -1894,46 +1802,44 @@ describe('where a segment says it is concatenated', () => { + }); + + describe('reassembly bounds', () => { +- function collect( +- reassembler: Reassembler, +- reference: number, +- part: number, +- total: number, +- ): Collected { +- return collectPdu(reassembler, segment(reference, part, total)); ++ type ReassemblerTuning = { max?: number; maxOctets?: number; newId?: () => string; now?: () => number; onLost?: (lost: LostGroup) => void; timeout?: number }; ++ ++ function reassembler(tuning: ReassemblerTuning = {}): Reassembler { ++ return new Reassembler({ ++ log: silentLog, ++ max: tuning.max ?? 10, ++ maxOctets: tuning.maxOctets, ++ newId: tuning.newId, ++ now: tuning.now ?? (() => 0), ++ onLost: tuning.onLost ?? (() => undefined), ++ timeout: tuning.timeout ?? 60_000, ++ }); + } + +- function collectSar( +- reassembler: Reassembler, +- reference: number, +- part: number, +- total: number, +- ): Collected { +- return collectPdu(reassembler, sarSegment(reference, part, total)); ++ function collect(store: Reassembler, reference: number, part: number, total: number): Collected { ++ return collectPdu(store, segment(reference, part, total)); ++ } ++ ++ function collectSar(store: Reassembler, reference: number, part: number, total: number): Collected { ++ return collectPdu(store, sarSegment(reference, part, total)); + } + + test('hands back every segment in order once the last one arrives', () => { +- const reassembler = new Reassembler({ +- log: silentLog, +- max: 10, +- now: () => 0, +- onLost: () => undefined, +- timeout: 60_000, +- }); +- const second = collect(reassembler, 4, 2, 3); +- const third = collect(reassembler, 4, 3, 3); ++ const store = reassembler(); ++ const second = collect(store, 4, 2, 3); ++ const third = collect(store, 4, 3, 3); + + assert.ok(second.kept); + assert.ok(third.kept); + assert.equal(second.whole, undefined); + assert.equal(third.whole, undefined); + +- const collected = collect(reassembler, 4, 1, 3); ++ const collected = collect(store, 4, 1, 3); + + assert.ok(collected.kept); + assert.ok(collected.whole); + assert.deepEqual(collected.whole.map(pduObj => pduObj.seqNr), [1, 2, 3]); +- assert.equal(reassembler.size, 0); ++ assert.equal(store.size, 0); + + // One id base per group: every segment of it was answered with a part of that base. + assert.equal(second.smsId, collected.smsId); +@@ -1942,20 +1848,13 @@ describe('reassembly bounds', () => { + + // The header is stripped by its own declared length, so the wider element assembles identically. + test('assembles a message numbered by a 16-bit UDH reference', () => { +- const reassembler = new Reassembler({ +- log: silentLog, +- max: 10, +- now: () => 0, +- onLost: () => undefined, +- timeout: 60_000, +- }); +- +- const first = collectPdu(reassembler, segment(0x2af1, 1, 2, 16)); ++ const store = reassembler(); ++ const first = collectPdu(store, segment(0x2af1, 1, 2, 16)); + + assert.ok(first.kept); + assert.equal(first.whole, undefined); + +- const collected = collectPdu(reassembler, segment(0x2af1, 2, 2, 16)); ++ const collected = collectPdu(store, segment(0x2af1, 2, 2, 16)); + + assert.ok(collected.kept); + assert.ok(collected.whole); +@@ -1965,56 +1864,27 @@ describe('reassembly bounds', () => { + // A group the store cannot hold at all is refused, not accepted and then thrown away. + test('refuses a lone segment whose own arrival overruns the octet cap', () => { + const lost: LostGroup[] = []; +- const reassembler = new Reassembler({ +- log: silentLog, +- max: 10, +- maxOctets: 10, +- now: () => 0, +- onLost: one => { lost.push(one); }, +- timeout: 60_000, +- }); +- const refused = collect(reassembler, 8, 1, 2); ++ const store = reassembler({ maxOctets: 10, onLost: one => { lost.push(one); } }); ++ const refused = collect(store, 8, 1, 2); + + assert.equal(refused.kept, false); +- assert.equal(reassembler.size, 0); ++ assert.equal(store.size, 0); + assert.deepEqual(lost, [], 'the peer holds the only segment there was, so nothing was lost'); + }); + + // The two addresses and the segment's and TLV's objects are 1322 octets, so only the 14 it carries can overrun 1330. + test('counts a body carried in message_payload against the octet cap', () => { +- function collectPayload(maxOctets: number): Collected { +- const reassembler = new Reassembler({ +- log: silentLog, +- max: 10, +- maxOctets, +- now: () => 0, +- onLost: () => undefined, +- timeout: 60_000, +- }); +- +- return collectPdu(reassembler, payloadSegment(9, 1, 2)); +- } ++ const collectPayload = (maxOctets: number): Collected => collectPdu(reassembler({ maxOctets }), payloadSegment(9, 1, 2)); + + assert.equal(collectPayload(1330).kept, false, 'a TLV body the cap cannot hold is refused, not dropped later'); + assert.equal(collectPayload(1340).kept, true); + }); + + test('counts the objects a segment and each of its TLVs hold against the octet cap, empty ones included', () => { +- function collectTlvs(maxOctets: number, tlvs: PduObject['tlvs']): Collected { +- const reassembler = new Reassembler({ +- log: silentLog, +- max: 10, +- maxOctets, +- now: () => 0, +- onLost: () => undefined, +- timeout: 60_000, +- }); +- +- return collectPdu(reassembler, { ...segment(9, 1, 2), tlvs }); +- } +- function collectCallbacks(maxOctets: number, tagValue: Buffer[]): Collected { +- return collectTlvs(maxOctets, { callback_num: { tagId: 0x0381, tagName: 'callback_num', tagValue } }); +- } ++ const collectTlvs = (maxOctets: number, tlvs: PduObject['tlvs']): Collected => ++ collectPdu(reassembler({ maxOctets }), { ...segment(9, 1, 2), tlvs }); ++ const collectCallbacks = (maxOctets: number, tagValue: Buffer[]): Collected => ++ collectTlvs(maxOctets, { callback_num: { tagId: 0x0381, tagName: 'callback_num', tagValue } }); + const unknownTags = Object.fromEntries(Array.from({ length: 10_000 }, (_, i) => { + const tagId = 0x4000 + i; + +@@ -2039,19 +1909,12 @@ describe('reassembly bounds', () => { + // The segments before it were answered ESME_ROK, so dropping those is not the same as refusing one. + test('reports the answered segments of a group that overruns the cap mid-message', () => { + const lost: LostGroup[] = []; +- const reassembler = new Reassembler({ +- log: silentLog, +- max: 10, +- // One segment is 1036 octets, so the second overruns a group already holding the first. +- maxOctets: 1050, +- now: () => 0, +- onLost: one => { lost.push(one); }, +- timeout: 60_000, +- }); ++ // One segment is 1036 octets, so the second overruns a group already holding the first. ++ const store = reassembler({ maxOctets: 1050, onLost: one => { lost.push(one); } }); + +- assert.equal(collect(reassembler, 8, 1, 3).kept, true); +- assert.equal(collect(reassembler, 8, 2, 3).kept, false); +- assert.equal(reassembler.size, 0); ++ assert.equal(collect(store, 8, 1, 3).kept, true); ++ assert.equal(collect(store, 8, 2, 3).kept, false); ++ assert.equal(store.size, 0); + // One of the two the group held is the refused segment, which the peer still has. + assert.deepEqual( + lost.map(one => ({ parts: one.parts, reason: one.reason, total: one.total })), +@@ -2061,13 +1924,7 @@ describe('reassembly bounds', () => { + + // An alphanumeric sender may carry the separator the key is built with, and two of them are two peers. + test('keeps two address pairs that differ only in where a separator sits apart', () => { +- const reassembler = new Reassembler({ +- log: silentLog, +- max: 10, +- now: () => 0, +- onLost: () => undefined, +- timeout: 60_000, +- }); ++ const store = reassembler(); + const addressed = (source: string, destination: string): PduObject => { + const carried = segment(3, 1, 2); + +@@ -2077,30 +1934,16 @@ describe('reassembly bounds', () => { + }; + }; + +- assert.equal(collectPdu(reassembler, addressed('A_B', 'C')).kept, true); +- assert.equal(collectPdu(reassembler, addressed('A', 'B_C')).kept, true); +- assert.equal(reassembler.size, 2, 'two senders, so two groups, and neither completes the other'); ++ assert.equal(collectPdu(store, addressed('A_B', 'C')).kept, true); ++ assert.equal(collectPdu(store, addressed('A', 'B_C')).kept, true); ++ assert.equal(store.size, 2, 'two senders, so two groups, and neither completes the other'); + +- reassembler.clear(); ++ store.clear(); + }); + + // Nothing about the bounds reads a UDH, and a group the TLVs numbered is bounded the same way. + test('bounds a sar_* group by the same count and octet caps', () => { +- const counted = new Reassembler({ +- log: silentLog, +- max: 1, +- now: () => 0, +- onLost: () => undefined, +- timeout: 60_000, +- }); +- const capped = (maxOctets: number): Reassembler => new Reassembler({ +- log: silentLog, +- max: 10, +- maxOctets, +- now: () => 0, +- onLost: () => undefined, +- timeout: 60_000, +- }); ++ const counted = reassembler({ max: 1 }); + + assert.equal(collectSar(counted, 1, 1, 2).kept, true); + assert.equal(collectSar(counted, 2, 1, 2).kept, true); +@@ -2108,8 +1951,8 @@ describe('reassembly bounds', () => { + counted.clear(); + + // A sar_* segment is the two 11-octet addresses, an 8-octet body, its own object and three TLVs'. +- assert.equal(collectSar(capped(1920), 3, 1, 2).kept, false); +- assert.equal(collectSar(capped(1930), 3, 1, 2).kept, true); ++ assert.equal(collectSar(reassembler({ maxOctets: 1920 }), 3, 1, 2).kept, false); ++ assert.equal(collectSar(reassembler({ maxOctets: 1930 }), 3, 1, 2).kept, true); + }); + + // Nothing else says a message the peer has already been answered for was thrown away. +@@ -2122,8 +1965,7 @@ describe('reassembly bounds', () => { + '0199e0ec-0e73-7d18-bb29-7c04ea51f3a9', + ]; + const lost: LostGroup[] = []; +- const reassembler = new Reassembler({ +- log: silentLog, ++ const store = reassembler({ + max: 1, + newId: () => ids[issued++] ?? '', + now: () => now, +@@ -2131,14 +1973,14 @@ describe('reassembly bounds', () => { + timeout: 60, + }); + +- collect(reassembler, 1, 1, 2); ++ collect(store, 1, 1, 2); + // A group numbered by the TLVs is given up on, and reported, exactly as a UDH group is. +- collectSar(reassembler, 2, 1, 3); ++ collectSar(store, 2, 1, 3); + ++ // The expired group goes on the next access, before the new one is stored. + now = 61; +- reassembler.sweep(); +- collect(reassembler, 3, 1, 2); +- reassembler.clear(); ++ collect(store, 3, 1, 2); ++ store.clear(); + + assert.deepEqual(lost.map(one => one.reason), ['evicted', 'expired', 'linkGone']); + assert.deepEqual(lost.map(one => one.parts), [1, 1, 1]); +@@ -2149,16 +1991,8 @@ describe('reassembly bounds', () => { + // The UDH is peer-controlled, and the default authenticate() accepts every peer. + test('refuses a segment whose concatenation metadata cannot be honoured', () => { + const warnings: string[] = []; +- const noop = (): void => undefined; +- const log: SmppLog = { +- debug: noop, +- error: noop, +- info: noop, +- verbose: noop, +- warn: msg => { warnings.push(msg); }, +- }; +- const reassembler = new Reassembler({ +- log, ++ const store = new Reassembler({ ++ log: { ...silentLog, warn: msg => { warnings.push(msg); } }, + max: 10, + now: () => 0, + onLost: () => undefined, +@@ -2166,114 +2000,88 @@ describe('reassembly bounds', () => { + }); + + assert.deepEqual( +- [collect(reassembler, 1, 1, 0), collect(reassembler, 2, 0, 3), collect(reassembler, 3, 4, 3)] ++ [collect(store, 1, 1, 0), collect(store, 2, 0, 3), collect(store, 3, 4, 3)] + .map(one => (one.kept ? undefined : one.refusal)), + Array(3).fill('unplaceable'), + ); +- assert.equal(reassembler.size, 0); ++ assert.equal(store.size, 0); + assert.deepEqual(warnings, Array(3).fill('reassembler - dropping an impossibly numbered segment')); + }); + + // Parts 1/2 then 2/3 would otherwise complete the stored two-part group, truncating the message. + test('refuses a segment that renumbers how many parts the message has', () => { +- const reassembler = new Reassembler({ +- log: silentLog, +- max: 10, +- now: () => 0, +- onLost: () => undefined, +- timeout: 60_000, +- }); +- +- const first = collect(reassembler, 9, 1, 2); ++ const store = reassembler(); ++ const first = collect(store, 9, 1, 2); + + assert.ok(first.kept); + assert.equal(first.whole, undefined); +- assert.equal(collect(reassembler, 9, 2, 3).kept, false); +- assert.equal(reassembler.size, 1); ++ assert.equal(collect(store, 9, 2, 3).kept, false); ++ assert.equal(store.size, 1); + +- reassembler.clear(); ++ store.clear(); + }); + + // 0.4.0 held incomplete groups without limit and swept them only when other traffic arrived. + test('drops the oldest incomplete message once the cap is reached', () => { +- const reassembler = new Reassembler({ +- log: silentLog, +- max: 2, +- now: () => 0, +- onLost: () => undefined, +- timeout: 60_000, +- }); ++ const store = reassembler({ max: 2 }); + + for (const reference of [1, 2, 3]) { +- const collected = collect(reassembler, reference, 1, 2); ++ const collected = collect(store, reference, 1, 2); + + assert.ok(collected.kept); + assert.equal(collected.whole, undefined); + } + + // Completing the first one must not produce a message: it was evicted. +- const reopened = collect(reassembler, 1, 2, 2); ++ const reopened = collect(store, 1, 2, 2); + + assert.ok(reopened.kept); + assert.equal(reopened.whole, undefined); +- assert.equal(reassembler.size, 2); ++ assert.equal(store.size, 2); + +- reassembler.clear(); ++ store.clear(); + }); + + test('drops the oldest incomplete message once the retained octets exceed the cap', () => { +- const reassembler = new Reassembler({ +- log: silentLog, +- max: 10, +- // One segment is 1036 octets: 14 of short_message, the two 11-octet addresses and its object. +- maxOctets: 2100, +- now: () => 0, +- onLost: () => undefined, +- timeout: 60_000, +- }); ++ // One segment is 1036 octets: 14 of short_message, the two 11-octet addresses and its object. ++ const store = reassembler({ maxOctets: 2100 }); + + for (const reference of [1, 2, 3]) { +- const collected = collect(reassembler, reference, 1, 2); ++ const collected = collect(store, reference, 1, 2); + + assert.ok(collected.kept); + assert.equal(collected.whole, undefined); + } + +- assert.equal(reassembler.size, 2); ++ assert.equal(store.size, 2); + +- const reopened = collect(reassembler, 1, 2, 2); ++ const reopened = collect(store, 1, 2, 2); + + assert.ok(reopened.kept); + assert.equal(reopened.whole, undefined); +- assert.ok(collect(reassembler, 3, 1, 2).kept); +- assert.equal(reassembler.size, 2, 'a segment sent again replaces its octets rather than adding them'); ++ assert.ok(collect(store, 3, 1, 2).kept); ++ assert.equal(store.size, 2, 'a segment sent again replaces its octets rather than adding them'); + +- reassembler.clear(); ++ store.clear(); + }); + + // A retained subarray keeps its whole framed PDU alive, up to maxPduLength per segment. + test('copies a segment out of the buffer it arrived in', () => { +- const reassembler = new Reassembler({ +- log: silentLog, +- max: 10, +- now: () => 0, +- onLost: () => undefined, +- timeout: 60_000, +- }); ++ const store = reassembler(); + const framed = Buffer.alloc(1024); + const first = segment(6, 1, 2); + + Buffer.concat([Buffer.from([0x05, 0x00, 0x03, 6, 2, 1]), Buffer.from('fragment')]).copy(framed); + first.params.short_message = framed.subarray(0, 14); + +- const held = collectPdu(reassembler, first); ++ const held = collectPdu(store, first); + + assert.ok(held.kept); + assert.equal(held.whole, undefined); + + framed.fill(0x00); + +- const collected = collect(reassembler, 6, 2, 2); ++ const collected = collect(store, 6, 2, 2); + + assert.ok(collected.kept); + assert.ok(collected.whole); +@@ -2282,14 +2090,8 @@ describe('reassembly bounds', () => { + + test('expires an incomplete message once its timeout has passed', () => { + let now = 0; +- const reassembler = new Reassembler({ +- log: silentLog, +- max: 10, +- now: () => now, +- onLost: () => undefined, +- timeout: 60, +- }); +- const first = collect(reassembler, 9, 1, 2); ++ const store = reassembler({ now: () => now, timeout: 60 }); ++ const first = collect(store, 9, 1, 2); + + assert.ok(first.kept); + assert.equal(first.whole, undefined); +@@ -2297,14 +2099,14 @@ describe('reassembly bounds', () => { + now = 61; + + // The other half arrives after the group expired, so it starts a new, still-incomplete one. +- const late = collect(reassembler, 9, 2, 2); ++ const late = collect(store, 9, 2, 2); + + assert.ok(late.kept); + assert.equal(late.whole, undefined); + assert.notEqual(late.smsId, first.smsId); +- assert.equal(reassembler.size, 1); ++ assert.equal(store.size, 1); + +- reassembler.clear(); ++ store.clear(); + }); + }); + +@@ -2345,12 +2147,12 @@ describe('a peer that sends the next segment only once the last one is answered' + return segments; + } + +- async function submit(session: Session, segment: Buffer): Promise { +- const answered = await session.send({ ++ async function submit(esme: SmppClient, part: Buffer): Promise { ++ const answered = await esme.send({ + cmdName: 'submit_sm', + params: submitSmParams( + { from: '46701113311', message: text, to: '46709771337' }, +- segment, ++ part, + { encoding: 'ASCII', multipart: true }, + ), + }); +@@ -2361,31 +2163,29 @@ describe('a peer that sends the next segment only once the last one is answered' + return answered.pduObj; + } + +- async function submitSerially(session: Session, reference: number): Promise { ++ async function submitSerially(esme: SmppClient, reference: number): Promise { + const answers: PduObject[] = []; + +- for (const segment of segmentsOf(reference)) { +- answers.push(await submit(session, segment)); ++ for (const part of segmentsOf(reference)) { ++ answers.push(await submit(esme, part)); + } + + return answers; + } + + test('gets every segment answered as it arrives, and the application one whole message', async t => { +- const smpp = await startServer(t); + const messages: Sms[] = []; +- const incoming = once(resolve => { +- smpp.on('session', bound => bound.on('sms', sms => { ++ const handed = handedOver(); ++ const smpp = await startServer(t, { ++ onSms: sms => { + messages.push(sms); +- resolve(sms); +- })); +- }); +- const { session } = await connect(t, smpp, { responseTimeout: 1000 }); + +- assert.ok(session); +- +- const answers = await submitSerially(session, 0x2A); +- const sms = await incoming; ++ return handed.onSms(sms); ++ }, ++ }); ++ const esme = await bound(t, smpp, { responseTimeout: 1000 }); ++ const answers = await submitSerially(esme, 0x2A); ++ const sms = await handed.sms; + + assert.equal(sms.message, text); + assert.equal(sms.pduObjs.length, answers.length); +@@ -2397,19 +2197,15 @@ describe('a peer that sends the next segment only once the last one is answered' + answers.map((_answer, index) => `${sms.smsId}-${String(index + 1)}`), + ); + assert.deepEqual(await sms.sendResp(), {}); ++ handed.release(); + }); + + // The documented single-segment contract, which the segment-by-segment answer must not touch. + test('answers a single-segment message only once the application does, with the id it chose', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', bound => bound.on('sms', resolve)); +- }); +- const { session } = await connect(t, smpp); +- +- assert.ok(session); +- +- const submitted = session.send({ ++ const handed = handedOver(); ++ const smpp = await startServer(t, { onSms: handed.onSms }); ++ const esme = await bound(t, smpp); ++ const submitted = esme.send({ + cmdName: 'submit_sm', + params: { + destination_addr: '46709771337', +@@ -2417,7 +2213,7 @@ describe('a peer that sends the next segment only once the last one is answered' + source_addr: '46701113311', + }, + }); +- const sms = await incoming; ++ const sms = await handed.sms; + + assert.equal(await within(150, submitted), undefined, 'nothing may answer for the application'); + assert.equal(sms.answeredOnArrival, false); +@@ -2430,20 +2226,17 @@ describe('a peer that sends the next segment only once the last one is answered' + paramText(answered.pduObj.params.message_id), + '0199e0e9-4a3e-7c62-9a4b-1f0c5d7e8a21', + ); ++ handed.release(); + }); + + test('refuses an id and a refusing status for segments already on the wire', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', bound => bound.on('sms', resolve)); +- }); +- const { session } = await connect(t, smpp, { responseTimeout: 1000 }); ++ const handed = handedOver(); ++ const smpp = await startServer(t, { onSms: handed.onSms }); ++ const esme = await bound(t, smpp, { responseTimeout: 1000 }); + +- assert.ok(session); ++ await submitSerially(esme, 0x2B); + +- await submitSerially(session, 0x2B); +- +- const sms = await incoming; ++ const sms = await handed.sms; + const named = await sms.sendResp({ smsId: '0199e0ea-1f3d-7ab4-8c21-6d4e5f0a9b73' }); + const refused = await sms.sendResp({ status: 'ESME_RMSGQFUL' }); + +@@ -2451,26 +2244,42 @@ describe('a peer that sends the next segment only once the last one is answered' + assert.match(refused.err?.message ?? '', /onRequest/); + assert.deepEqual(await sms.sendResp({ status: 'ESME_ROK' }), {}); + assert.equal(sms.answeredOnArrival, true); ++ handed.release(); + }); + +- test('reports a half-arrived message it has already answered, and holds nothing after', async t => { +- const smpp = await startServer(t, { reassemblyTimeout: 60 }); +- const messages: Sms[] = []; +- const lost = once(resolve => { +- smpp.on('session', bound => { +- bound.on('sessionError', resolve); +- bound.on('sms', sms => { messages.push(sms); }); ++ // The handler's return is the answer, and a segment answered on arrival has none left to give. ++ test('reports a handler that returns an answer for a message answered on arrival', async t => { ++ const reported = latch(); ++ const failures: string[] = []; ++ const smpp = await startServer(t, { onSms: () => ({ status: 'ESME_RMSGQFUL' }) }); ++ ++ smpp.on('session', session => { ++ session.on('sessionError', err => { ++ failures.push(err.message); ++ reported.open(); + }); + }); +- const { session } = await connect(t, smpp, { responseTimeout: 1000 }); + +- assert.ok(session); ++ const esme = await bound(t, smpp, { responseTimeout: 1000 }); ++ ++ await submitSerially(esme, 0x2E); ++ await reported.passed; + ++ assert.match(failures[0] ?? '', /nothing left to refuse/); ++ }); ++ ++ test('reports a half-arrived message it has already answered, and holds nothing after', async t => { ++ const messages: Sms[] = []; ++ const smpp = await startServer(t, { onSms: sms => { messages.push(sms); }, reassemblyTimeout: 60 }); ++ const lost = once(resolve => { ++ smpp.on('session', session => { session.on('sessionError', resolve); }); ++ }); ++ const esme = await bound(t, smpp, { responseTimeout: 1000 }); + const [first] = segmentsOf(0x2C); + + assert.ok(first); + +- const answered = await submit(session, first); ++ const answered = await submit(esme, first); + + assert.equal(answered.cmdStatus, 'ESME_ROK'); + assert.match((await lost).message, /Gave up 1 of \d+ segments/); +@@ -2478,18 +2287,16 @@ describe('a peer that sends the next segment only once the last one is answered' + assert.deepEqual(await peerOf(smpp).close(), {}, 'a group nothing completed is not held'); + }); + +- test('reports a drop once as disconnected when a listener closes the session over the segments it lost', async t => { ++ // The application closed over the drop, so no retry follows it and the drop is the end. ++ test('reports a drop the application closes over as close alone', async t => { + const smpp = await startServer(t); +- const { session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } }); +- +- assert.ok(session); +- ++ const esme = await bound(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } }); + const events: string[] = []; +- const closed = once(resolve => { session.on('close', () => { resolve(true); }); }); ++ const closed = once(resolve => { esme.on('close', () => { resolve(true); }); }); + +- session.on('close', () => { events.push('close'); }); +- session.on('disconnected', () => { events.push('disconnected'); }); +- session.on('sessionError', () => { void session.close(); }); ++ esme.on('close', () => { events.push('close'); }); ++ esme.on('disconnected', () => { events.push('disconnected'); }); ++ esme.on('sessionError', () => { void esme.close(); }); + + const [first] = segmentsOf(0x2D); + +@@ -2508,20 +2315,16 @@ describe('a peer that sends the next segment only once the last one is answered' + await peerOf(smpp).close(); + await closed; + +- assert.deepEqual(events, ['disconnected', 'close']); ++ assert.deepEqual(events, ['close']); + }); + +- test('close() still waits for a concatenated message the application has not answered', async t => { +- const smpp = await startServer(t, { shutdownTimeout: 50 }); +- const incoming = once(resolve => { +- smpp.on('session', bound => bound.on('sms', resolve)); +- }); +- const { session } = await connect(t, smpp, { responseTimeout: 1000 }); +- +- assert.ok(session); ++ test('close() still waits for a concatenated message the application has not returned from', async t => { ++ const handed = handedOver(); ++ const smpp = await startServer(t, { onSms: handed.onSms, shutdownTimeout: 50 }); ++ const esme = await bound(t, smpp, { responseTimeout: 1000 }); + +- await submitSerially(session, 0x2D); +- await incoming; ++ await submitSerially(esme, 0x2D); ++ await handed.sms; + + const closed = await peerOf(smpp).close(); + +@@ -2531,15 +2334,10 @@ describe('a peer that sends the next segment only once the last one is answered' + + // pduObjs.length is 1 either way here, so answeredOnArrival is the only thing that can say. + test('marks a one-part concatenated message answered, as its segment count cannot', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', bound => bound.on('sms', resolve)); +- }); +- const { session } = await connect(t, smpp, { responseTimeout: 1000 }); +- +- assert.ok(session); +- +- const answered = await session.send({ ++ const handed = handedOver(); ++ const smpp = await startServer(t, { onSms: handed.onSms }); ++ const esme = await bound(t, smpp, { responseTimeout: 1000 }); ++ const answered = await esme.send({ + cmdName: 'submit_sm', + params: { + destination_addr: '46709771337', +@@ -2551,7 +2349,7 @@ describe('a peer that sends the next segment only once the last one is answered' + source_addr: '46701113311', + }, + }); +- const sms = await incoming; ++ const sms = await handed.sms; + + assert.equal(answered.err, undefined); + assert.ok(answered.pduObj); +@@ -2560,20 +2358,15 @@ describe('a peer that sends the next segment only once the last one is answered' + assert.equal(sms.pduObjs.length, 1); + assert.equal(sms.answeredOnArrival, true); + assert.deepEqual(await sms.sendResp(), {}); ++ handed.release(); + }); + + // esm_class said there was a UDH, and there is no group its concatenation fields can join. + test('answers a segment whose UDH cannot be honoured rather than leaving the peer waiting', async t => { +- const smpp = await startServer(t); + const messages: Sms[] = []; +- +- smpp.on('session', bound => bound.on('sms', sms => { messages.push(sms); })); +- +- const { session } = await connect(t, smpp, { responseTimeout: 1000 }); +- +- assert.ok(session); +- +- const answered = await session.send({ ++ const smpp = await startServer(t, { onSms: sms => { messages.push(sms); } }); ++ const esme = await bound(t, smpp, { responseTimeout: 1000 }); ++ const answered = await esme.send({ + cmdName: 'submit_sm', + params: { + destination_addr: '46709771337', +@@ -2594,16 +2387,10 @@ describe('a peer that sends the next segment only once the last one is answered' + + // Its esm_class is 0x00 and correct, so the refusal names the optional parameters instead. + test('refuses a sar_* segment the TLVs number impossibly by naming those TLVs', async t => { +- const smpp = await startServer(t); + const messages: Sms[] = []; +- +- smpp.on('session', bound => bound.on('sms', sms => { messages.push(sms); })); +- +- const { session } = await connect(t, smpp, { responseTimeout: 1000 }); +- +- assert.ok(session); +- +- const answered = await session.send({ ++ const smpp = await startServer(t, { onSms: sms => { messages.push(sms); } }); ++ const esme = await bound(t, smpp, { responseTimeout: 1000 }); ++ const answered = await esme.send({ + cmdName: 'submit_sm', + params: { + destination_addr: '46709771337', +@@ -2639,17 +2426,17 @@ describe('AbortSignal on a send', () => { + + const address = silent.address(); + const port = typeof address === 'object' && address !== null ? address.port : 0; +- const { err, session } = await client({ port, responseTimeout: 10_000 }); ++ const { err, client: esme } = await client({ port, responseTimeout: 10_000 }); + + assert.equal(err, undefined); +- assert.ok(session); +- closeAfter(t, session); ++ assert.ok(esme); ++ closeAfter(t, esme); + + const controller = new AbortController(); + + setTimeout(() => { controller.abort(); }, 50); + +- const sent = await session.sendSms( ++ const sent = await esme.sendSms( + { from: '46701113311', message: 'never answered', to: '46709771337' }, + { signal: controller.signal }, + ); +@@ -2657,32 +2444,32 @@ describe('AbortSignal on a send', () => { + assert.ok(sent.err instanceof Error); + }); + +- /** The peer answers nothing, so the single slot stays taken for the life of the test. */ ++ /** The peer never returns from its handler, so the single slot stays taken for the life of the test. */ + async function oneSlotHeld( + t: TestContext, + options: Parameters[0] = {}, +- ): Promise { +- const smpp = await startServer(t); +- const onWire = once(resolve => { +- smpp.on('session', bound => bound.on('incomingPduObj', resolve)); +- }); +- +- smpp.on('session', bound => bound.on('sms', () => undefined)); ++ ): Promise { ++ const onWire = latch(); ++ const smpp = await startServer(t, { ++ onSms: () => { ++ onWire.open(); + +- const { session } = await connect(t, smpp, { maxOutstanding: 1, ...options }); ++ return neverReturns(); ++ }, ++ }); ++ const esme = await bound(t, smpp, { maxOutstanding: 1, ...options }); + +- assert.ok(session); +- void session.sendSms({ from: '46701113311', message: 'holds the only slot', to: '46709771337' }); ++ void esme.sendSms({ from: '46701113311', message: 'holds the only slot', to: '46709771337' }); + +- await onWire; ++ await onWire.passed; + +- return session; ++ return esme; + } + + test('gives up on a send still queued behind a full window', async t => { +- const session = await oneSlotHeld(t, { responseTimeout: 10_000 }); ++ const esme = await oneSlotHeld(t, { responseTimeout: 10_000 }); + const controller = new AbortController(); +- const queued = session.sendSms( ++ const queued = esme.sendSms( + { from: '46701113311', message: 'queued behind the held slot', to: '46709771337' }, + { signal: controller.signal }, + ); +@@ -2698,9 +2485,9 @@ describe('AbortSignal on a send', () => { + }); + + test('gives up on a queued send where responseTimeout: 0 never would', async t => { +- const session = await oneSlotHeld(t, { responseTimeout: 0 }); ++ const esme = await oneSlotHeld(t, { responseTimeout: 0 }); + const controller = new AbortController(); +- const queued = session.sendSms( ++ const queued = esme.sendSms( + { from: '46701113311', message: 'queued with nothing else to end the wait', to: '46709771337' }, + { signal: controller.signal }, + ); +@@ -2716,26 +2503,24 @@ describe('AbortSignal on a send', () => { + }); + + test('leaves the freed slot to the next send rather than to the waiter that gave up', async t => { +- const smpp = await startServer(t); +- const holding = once(resolve => { smpp.on('session', bound => bound.on('sms', resolve)); }); ++ const handed = handedOver(); + let firstTaken = false; +- +- smpp.on('session', bound => { +- bound.on('sms', async sms => { +- if (firstTaken) await sms.sendResp(); ++ const smpp = await startServer(t, { ++ onSms: sms => { ++ if (firstTaken) return undefined; + + firstTaken = true; +- }); +- }); + +- const { session } = await connect(t, smpp, { maxOutstanding: 1, responseTimeout: 10_000 }); ++ return handed.onSms(sms); ++ }, ++ }); ++ const esme = await bound(t, smpp, { maxOutstanding: 1, responseTimeout: 10_000 }); + +- assert.ok(session); +- void session.sendSms({ from: '46701113311', message: 'holds the only slot', to: '46709771337' }); ++ void esme.sendSms({ from: '46701113311', message: 'holds the only slot', to: '46709771337' }); + +- const held = await holding; ++ const held = await handed.sms; + const controller = new AbortController(); +- const abandoned = session.sendSms( ++ const abandoned = esme.sendSms( + { from: '46701113311', message: 'abandoned in the queue', to: '46709771337' }, + { signal: controller.signal }, + ); +@@ -2749,8 +2534,9 @@ describe('AbortSignal on a send', () => { + // Any other error means it never reached the queue, so there was no waiter to strand. + assert.match(gaveUp.err?.message ?? '', /Aborted while waiting for a send window slot/); + await held.sendResp(); ++ handed.release(); + +- const following = await within(1000, session.sendSms({ ++ const following = await within(1000, esme.sendSms({ + from: '46701113311', + message: 'takes the freed slot', + to: '46709771337', +@@ -2762,29 +2548,25 @@ describe('AbortSignal on a send', () => { + }); + + describe('graceful shutdown', () => { ++ /** A submit whose handler is running, and stays running until the test releases it. */ + async function submitInFlight( + t: TestContext, + options: Parameters[0] = {}, + serverOptions: Parameters[0] = {}, + message = 'answer me', + ) { +- const smpp = await startServer(t, serverOptions); +- const incoming = once(resolve => { +- smpp.on('session', bound => bound.on('sms', resolve)); +- }); +- const { session } = await connect(t, smpp, options); ++ const handed = handedOver(); ++ const smpp = await startServer(t, { onSms: handed.onSms, ...serverOptions }); ++ const esme = await bound(t, smpp, options); ++ const sent = esme.sendSms({ from: '46701113311', message, to: '46709771337' }); + +- assert.ok(session); +- +- const sent = session.sendSms({ from: '46701113311', message, to: '46709771337' }); +- +- return { sent, session, sms: await incoming, smpp }; ++ return { esme, release: handed.release, sent, sms: await handed.sms, smpp }; + } + + test('close() waits out a submit already on the wire and refuses new ones', async t => { +- const { sent, session, sms } = await submitInFlight(t); +- const closed = session.close(); +- const refused = await session.sendSms({ ++ const { esme, release, sent, sms } = await submitInFlight(t); ++ const closed = esme.close(); ++ const refused = await esme.sendSms({ + from: '46701113311', + message: 'too late', + to: '46709771337', +@@ -2794,6 +2576,7 @@ describe('graceful shutdown', () => { + assert.equal(refused.err.message, 'Session is shutting down'); + + await sms.sendResp({ smsId: 'answered-while-draining' }); ++ release(); + + assert.deepEqual((await sent).smsIds, ['answered-while-draining']); + assert.deepEqual(await closed, {}); +@@ -2801,41 +2584,44 @@ describe('graceful shutdown', () => { + + // The drain refuses sends; a response was never a send, and saying so is the more useful answer. + test('names a response put through send() as the misuse it is, even mid-shutdown', async t => { +- const { sent, session, sms } = await submitInFlight(t); +- const closing = session.close(); +- const refused = await session.send({ cmdName: 'submit_sm_resp' }); ++ const { esme, release, sent, sms } = await submitInFlight(t); ++ const closing = esme.close(); ++ const refused = await esme.send({ cmdName: 'submit_sm_resp' }); + + assert.ok(refused.err instanceof Error); + assert.match(refused.err.message, /Use sendReturn\(\)/); + + await sms.sendResp({ smsId: 'answered-after-the-misuse' }); ++ release(); + + assert.deepEqual((await sent).smsIds, ['answered-after-the-misuse']); + assert.deepEqual(await closing, {}); + }); + + test('unbind() waits out a submit already on the wire before it unbinds', async t => { +- const { sent, session, sms } = await submitInFlight(t); +- const unbound = session.unbind(); ++ const { esme, release, sent, sms } = await submitInFlight(t); ++ const unbound = esme.unbind(); + + await sms.sendResp({ smsId: 'answered-before-unbind' }); ++ release(); + + assert.deepEqual((await sent).smsIds, ['answered-before-unbind']); + assert.deepEqual(await unbound, {}); + }); + +- test('close() waits for a message the application has not answered yet', async t => { +- const { sent, smpp, sms } = await submitInFlight(t); ++ test('close() waits for a handler that has not returned yet', async t => { ++ const { release, sent, smpp, sms } = await submitInFlight(t); + const closing = peerOf(smpp).close(); + + await delay(50); + await sms.sendResp({ smsId: 'answered-during-the-inbound-drain' }); ++ release(); + + assert.deepEqual(await closing, {}); + assert.deepEqual((await sent).smsIds, ['answered-during-the-inbound-drain']); + }); + +- test('gives up on a message the application never answers', async t => { ++ test('gives up on a handler that never returns', async t => { + const { smpp } = await submitInFlight(t, {}, { shutdownTimeout: 50 }); + const closed = await peerOf(smpp).close(); + +@@ -2843,50 +2629,36 @@ describe('graceful shutdown', () => { + assert.match(closed.err.message, /1 message\(s\) unanswered/); + }); + +- // Waiting forever is safe for the peer, which every request times out on. The application is not. +- test('falls back to responseTimeout for a held message when the shutdown waits forever', async t => { +- const { smpp } = await submitInFlight(t, {}, { responseTimeout: 200, shutdownTimeout: 0 }); +- const started = Date.now(); +- const closed = await peerOf(smpp).close(); +- const waited = Date.now() - started; +- +- assert.ok(closed.err instanceof Error); +- assert.match(closed.err.message, /1 message\(s\) unanswered/); +- assert.ok(waited >= 190, `waited ${String(waited)} ms, so the fallback was not what bounded it`); +- assert.ok(waited < 2000); +- }); +- + // leftOf() floors what is left at 1 ms: at 0 the request half would read "wait forever" instead. +- test('still ends when the message half has spent the whole shutdown budget', async t => { +- const { smpp } = await submitInFlight(t, {}, { shutdownTimeout: 100 }); +- const bound = peerOf(smpp); +- // The client listens for no 'sms', so this one is never answered and stays in the window. +- const unanswered = bound.send({ +- cmdName: 'submit_sm', ++ test('still ends when the handlers have spent the whole shutdown budget', async t => { ++ const { esme, smpp } = await submitInFlight(t, { onSms: neverReturns }, { shutdownTimeout: 100 }); ++ const peer = peerOf(smpp); ++ // The client's handler never returns, so this one stays in the server's window. ++ const unanswered = peer.send({ ++ cmdName: 'deliver_sm', + params: { + destination_addr: '46701113311', + short_message: 'nothing answers this', + source_addr: '46709771337', + }, + }); +- const closed = await Promise.race([ +- bound.close(), +- new Promise<{ err?: Error }>(resolve => { +- setTimeout(() => { resolve({ err: new Error('close() never returned') }); }, 2000).unref(); +- }), +- ]); + ++ await delay(20); ++ ++ const closed = await within(2000, peer.close()); ++ ++ assert.ok(closed, 'close() never returned'); + assert.match(closed.err?.message ?? '', /1 message\(s\) unanswered; .*1 request\(s\) unfinished/); + assert.ok((await unanswered).err instanceof Error); ++ assert.ok(esme); + }); + +- // The README's own listener answers and then sends its receipt, one turn later. Multipart, because +- // a receipt sent one-after-a-response outruns that turn on every segment past the first. ++ // Multipart, because a receipt sent one-after-a-response outruns a drain on every segment past the first. + test('a receipt sent right after the response still goes out mid-drain', async t => { +- const { sent, session, smpp, sms } = await submitInFlight(t, {}, {}, 'x'.repeat(400)); ++ const { esme, release, sent, smpp, sms } = await submitInFlight(t, {}, {}, 'x'.repeat(400)); + const received: Dlr[] = []; + const receipts = once(resolve => { +- session.on('dlr', dlr => { ++ esme.on('dlr', dlr => { + received.push(dlr); + + if (received.length === 3) resolve(received); +@@ -2899,93 +2671,52 @@ describe('graceful shutdown', () => { + const receiptSent = await sms.sendDlr('DELIVERED'); + const ids = [1, 2, 3].map(part => `${sms.smsId}-${String(part)}`); + ++ release(); + assert.equal(receiptSent.err, undefined); + assert.deepEqual((await receipts).map(dlr => dlr.smsId), ids); + assert.deepEqual(await closing, {}); + assert.deepEqual((await sent).smsIds, ids); + }); + +- test('a message no listener took does not hold the shutdown up', async t => { ++ test('a message no handler took is refused at once and holds the shutdown up for nothing', async t => { + const smpp = await startServer(t, { shutdownTimeout: 30_000 }); +- const { session } = await connect(t, smpp); +- +- assert.ok(session); +- +- const bound = peerOf(smpp); +- const arrived = once(resolve => { +- bound.on('incomingPduObj', pduObj => { +- if (pduObj.cmdName === 'submit_sm') resolve(pduObj); +- }); +- }); +- const sent = session.sendSms({ ++ const esme = await bound(t, smpp); ++ const sent = await esme.sendSms({ + from: '46701113311', + message: 'nobody is listening', + to: '46709771337', + }); + +- await arrived; +- await delay(50); ++ assert.match(sent.err?.message ?? '', /ESME_RTHROTTLED/, 'the peer keeps a message nothing took'); + + const started = Date.now(); + +- assert.deepEqual(await bound.close(), {}); ++ assert.deepEqual(await peerOf(smpp).close(), {}); + assert.ok(Date.now() - started < 1000); +- assert.ok((await sent).err instanceof Error); + }); + +- // emit() releases the hold of a listener that throws; one that rejects may cost no more than that. +- test('a listener that rejected before answering does not hold the shutdown up', async t => { +- const smpp = await startServer(t, { shutdownTimeout: 30_000 }); ++ test('a handler that rejected before answering refuses the message and does not hold the shutdown up', async t => { ++ const smpp = await startServer(t, { ++ onSms: () => Promise.reject(new Error('the handler gave up')), ++ shutdownTimeout: 30_000, ++ }); + const failed = once(resolve => { +- smpp.on('session', bound => { +- bound.on('sessionError', resolve); +- bound.on('sms', () => Promise.reject(new Error('the listener gave up'))); +- }); ++ smpp.on('session', session => { session.on('sessionError', resolve); }); + }); +- const { session } = await connect(t, smpp); +- +- assert.ok(session); +- +- const sent = session.sendSms({ ++ const esme = await bound(t, smpp); ++ const sent = await esme.sendSms({ + from: '46701113311', +- message: 'the listener rejects', ++ message: 'the handler rejects', + to: '46709771337', + }); + +- assert.equal((await failed).message, 'the listener gave up'); ++ assert.equal((await failed).message, 'the handler gave up'); ++ assert.match(sent.err?.message ?? '', /ESME_RTHROTTLED/); + + const started = Date.now(); + + assert.deepEqual(await peerOf(smpp).close(), {}); + assert.ok(Date.now() - started < 1000); +- assert.ok((await sent).err instanceof Error); +- }); +- +- test('waits for the listener still working when another one rejected', async t => { +- const smpp = await startServer(t, { shutdownTimeout: 30_000 }); +- const failed = once(resolve => { +- smpp.on('session', bound => { +- bound.on('sessionError', resolve); +- bound.on('sms', async sms => { +- await delay(100); +- await sms.sendResp({ smsId: 'answered-after-the-other-gave-up' }); +- }); +- bound.on('sms', () => Promise.reject(new Error('the audit listener gave up'))); +- }); +- }); +- const { session } = await connect(t, smpp); +- +- assert.ok(session); +- +- const sent = session.sendSms({ +- from: '46701113311', +- message: 'two listeners, one gives up', +- to: '46709771337', +- }); +- +- assert.equal((await failed).message, 'the audit listener gave up'); +- assert.deepEqual(await peerOf(smpp).close(), {}); +- assert.deepEqual((await sent).smsIds, ['answered-after-the-other-gave-up']); + }); + + // Nothing reached the peer, so a drain counting this answered would report an outcome that never was. +@@ -3000,8 +2731,8 @@ describe('graceful shutdown', () => { + }); + + test('gives up on a request that outlasts shutdownTimeout', async t => { +- const { sent, session } = await submitInFlight(t, { shutdownTimeout: 50 }); +- const closed = await session.close(); ++ const { esme, sent } = await submitInFlight(t, { shutdownTimeout: 50 }); ++ const closed = await esme.close(); + + assert.ok(closed.err instanceof Error); + assert.match(closed.err.message, /unfinished/); +@@ -3015,11 +2746,11 @@ describe('graceful shutdown', () => { + + // The window empties on a drop as well as on an answer, so it cannot be what the result reads. + test('reports a link that dropped mid-drain rather than calling it a clean shutdown', async t => { +- const { sent, session, smpp } = await submitInFlight(t); +- const closing = session.close(); ++ const { esme, sent, smpp } = await submitInFlight(t); ++ const closing = esme.close(); + +- for (const bound of smpp.sessions) { +- bound.sock.destroy(); ++ for (const peer of smpp.sessions) { ++ peer.sock.destroy(); + } + + const closed = await closing; +@@ -3031,29 +2762,29 @@ describe('graceful shutdown', () => { + + // The queued segments are the whole reason the drain waits on the window and not on the pending map. + test('counts the segments still queued behind a full window', async t => { +- // No 'sms' listener, so the single-segment message holding the only slot is never answered. +- const smpp = await startServer(t); +- const onWire = once(resolve => { +- smpp.on('session', bound => bound.on('incomingPduObj', resolve)); +- }); +- const { session } = await connect(t, smpp, { maxOutstanding: 1, shutdownTimeout: 50 }); +- +- assert.ok(session); ++ const onWire = latch(); ++ const smpp = await startServer(t, { ++ onSms: () => { ++ onWire.open(); + +- const holding = session.sendSms({ ++ return neverReturns(); ++ }, ++ }); ++ const esme = await bound(t, smpp, { maxOutstanding: 1, shutdownTimeout: 50 }); ++ const holding = esme.sendSms({ + from: '46701113311', + message: 'holds the only slot', + to: '46709771337', + }); +- const queued = session.sendSms({ ++ const queued = esme.sendSms({ + from: '46701113311', + message: 'x'.repeat(400), + to: '46709771337', + }); + +- await onWire; ++ await onWire.passed; + +- const closed = await session.close(); ++ const closed = await esme.close(); + + assert.ok(closed.err instanceof Error); + assert.match(closed.err.message, /4 request\(s\)/); +@@ -3062,17 +2793,18 @@ describe('graceful shutdown', () => { + }); + + test('an aborted close tears down at once instead of waiting out the drain', async t => { +- const { sent, session } = await submitInFlight(t, { shutdownTimeout: 30_000 }); ++ const { esme, sent } = await submitInFlight(t, { shutdownTimeout: 30_000 }); + const controller = new AbortController(); ++ const sock = linkOf(esme).sock; + const started = Date.now(); + + controller.abort(); + +- const closed = await session.close({ signal: controller.signal }); ++ const closed = await esme.close({ signal: controller.signal }); + + assert.ok(Date.now() - started < 1000); + assert.ok(closed.err instanceof Error); +- assert.ok(session.sock.destroyed); ++ assert.ok(sock.destroyed); + assert.ok((await sent).err instanceof Error); + }); + +@@ -3084,8 +2816,8 @@ describe('graceful shutdown', () => { + t.after(() => { silent.destroy(); }); + silent.resume(); + +- const bound = await arrived; +- const unanswered = bound.send({ cmdName: 'enquire_link' }); ++ const peer = await arrived; ++ const unanswered = peer.send({ cmdName: 'enquire_link' }); + const closing = smpp.close(); + const late = await new Promise(resolve => { + const sock = net.connect({ port: smpp.port }); +@@ -3111,9 +2843,9 @@ describe('graceful shutdown', () => { + t.after(() => { peer.destroy(); }); + peer.resume(); + +- const bound = await arrived; +- const ended = once(resolve => { bound.on('close', () => { resolve(true); }); }); +- const unanswered = bound.send({ cmdName: 'enquire_link' }); ++ const session = await arrived; ++ const ended = once(resolve => { session.on('close', () => { resolve(true); }); }); ++ const unanswered = session.send({ cmdName: 'enquire_link' }); + const { buffer } = objToPdu({ cmdName: 'unbind', seqNr: 1 }); + + assert.ok(buffer); +@@ -3123,107 +2855,74 @@ describe('graceful shutdown', () => { + assert.ok((await unanswered).err instanceof Error); + }); + +- test('does not report a reconnect on a session closed while it was coming back up', async t => { +- const accepted: net.Socket[] = []; +- const listener = net.createServer(sock => { accepted.push(sock); sock.resume(); }); +- +- await new Promise(resolve => { listener.listen(0, resolve); }); +- +- const address = listener.address(); +- const port = typeof address === 'object' && address !== null ? address.port : 0; +- const opened: net.Socket[] = []; +- const open = (): Promise> => new Promise(resolve => { +- const sock = net.connect({ port }, () => { resolve({ sock }); }); +- +- opened.push(sock); +- }); +- const first = await open(); +- +- assert.ok(first.sock); +- ++ test('does not report a reconnect on a client closed while it was coming back up', async t => { + const rebinding = latch(); + const release = latch(); +- const session = new Session({ +- reconnect: { +- connect: open, +- maxDelay: 20, +- minDelay: 10, +- onConnected: async () => { ++ let binds = 0; ++ const smpp = await startServer(t, { ++ authenticate: async () => { ++ binds++; ++ ++ if (binds > 1) { + rebinding.open(); + await release.passed; ++ } + +- return {}; +- }, ++ return true; + }, +- sock: first.sock, + }); ++ const esme = await bound(t, smpp, { reconnect: { maxDelay: 20, minDelay: 10 } }); + const reported: string[] = []; + +- closeAfter(t, session); +- t.after(() => { for (const sock of opened) sock.destroy(); }); +- closeListenerAfter(t, listener, accepted); +- session.on('reconnected', () => reported.push('reconnected')); +- first.sock.destroy(); ++ esme.on('reconnected', () => reported.push('reconnected')); ++ peerOf(smpp).sock.destroy(); + + assert.ok(await rebinding.passed); + +- const closing = session.close(); ++ const closing = esme.close(); + + release.open(); + await closing; + await delay(50); + + assert.deepEqual(reported, []); ++ assert.ok(await waitFor(() => smpp.sessions.size === 0), 'the session that came up too late was closed'); + }); + +- test('answers a peer\'s unbind before asking the session to end', async t => { ++ test('answers a peer\'s unbind before the session ends', async t => { + const session = new Session({ sock: new net.Socket() }); + + closeAfter(t, session); + + const calls: string[] = []; +- const incoming = incomingOn(session); +- const close = session.close.bind(session); + + session.sendReturn = pduObj => { + calls.push(pduObj.cmdName); ++ calls.push(session.sock.destroyed ? 'destroyed' : 'open'); + + return Promise.resolve({}); + }; +- session.close = options => { +- calls.push('close'); ++ arrives(session, { cmdName: 'unbind', seqNr: 1 }); + +- return close(options); +- }; +- +- await incoming.handle({ ...submitPdu(1), cmdId: 0x00000006, cmdName: 'unbind', params: {} }); +- +- assert.deepEqual(calls, ['unbind', 'close']); ++ assert.ok(await waitFor(() => session.sock.destroyed)); ++ assert.deepEqual(calls, ['unbind', 'open']); + }); + }); + + describe('message id notation', () => { +- async function sendOne(session: Session, message: string): Promise { +- return session.sendSms({ dlr: true, from: '46701113311', message, to: '46709771337' }); ++ async function sendOne(esme: SmppClient, message: string): Promise { ++ return esme.sendSms({ dlr: true, from: '46701113311', message, to: '46709771337' }); + } + + test('correlates a hex submit_sm_resp against a decimal receipt', async t => { +- const smpp = await startServer(t); +- +- smpp.on('session', bound => { +- bound.on('sms', sms => { void sms.sendResp({ smsId: '1a2b' }); }); +- }); +- +- const { session } = await connect(t, smpp, { ++ const smpp = await startServer(t, { onSms: () => ({ smsId: '1a2b' }) }); ++ const esme = await bound(t, smpp, { + smsIdFormat: { receipt: 'decimal', submitResp: 'hex' }, + }); +- +- assert.ok(session); +- + const reported = once<[Dlr, PduObject]>(resolve => { +- session.on('dlr', (dlr, pduObj) => { resolve([dlr, pduObj]); }); ++ esme.on('dlr', (dlr, pduObj) => { resolve([dlr, pduObj]); }); + }); +- const sent = await sendOne(session, 'one segment'); ++ const sent = await sendOne(esme, 'one segment'); + + assert.deepEqual(sent.smsIds, ['6699']); + assert.equal(paramText(sent.pduObjs[0]?.params.message_id), '1a2b', 'the PDU keeps the id it carried'); +@@ -3238,22 +2937,18 @@ describe('message id notation', () => { + }); + + test('leaves the segment ids of a multipart send to merge as they are', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', bound => bound.on('sms', sms => { +- resolve(sms); +- void sms.sendResp(); +- })); +- }); +- const { session } = await connect(t, smpp, { ++ const handed = handedOver(); ++ const smpp = await startServer(t, { onSms: handed.onSms }); ++ const esme = await bound(t, smpp, { + smsIdFormat: { receipt: 'decimal', submitResp: 'hex' }, + }); ++ const merged = once(resolve => { esme.on('messageDlr', resolve); }); ++ const sending = sendOne(esme, 'x'.repeat(200)); ++ const sms = await handed.sms; + +- assert.ok(session); ++ handed.release(); + +- const merged = once(resolve => { session.on('messageDlr', resolve); }); +- const sent = await sendOne(session, 'x'.repeat(200)); +- const sms = await incoming; ++ const sent = await sending; + + assert.deepEqual(sent.smsIds, [1, 2].map(part => `${sms.smsId}-${String(part)}`)); + +@@ -3279,3 +2974,79 @@ describe('message id notation', () => { + assert.equal(checkSessionOptions({ smsIdFormat: { submitResp: 'hex' } }).err, undefined); + }); + }); ++ ++describe('the onSms contract', () => { ++ async function submitted(t: TestContext, onSms: OnSms | undefined): Promise<{ esme: SmppClient; sent: SendSmsResult; smpp: SmppServer }> { ++ const smpp = await startServer(t, onSms ? { onSms } : {}); ++ const esme = await bound(t, smpp); ++ const sent = await esme.sendSms({ from: '46701113311', message: 'answered by the handler', to: '46709771337' }); ++ ++ return { esme, sent, smpp }; ++ } ++ ++ test('answers ESME_ROK under a generated id when the handler returns nothing', async t => { ++ let seen: Sms | undefined; ++ const { sent } = await submitted(t, sms => { seen = sms; }); ++ ++ assert.equal(sent.err, undefined); ++ assert.ok(seen); ++ assert.deepEqual(sent.smsIds, [seen.smsId]); ++ assert.match(seen.smsId, /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); ++ }); ++ ++ test('answers with the id or the status the handler returns', async t => { ++ const named = await submitted(t, () => ({ smsId: '0199e0eb-9d42-7bc6-8e70-51af3c92d6b8' })); ++ ++ assert.deepEqual(named.sent.smsIds, ['0199e0eb-9d42-7bc6-8e70-51af3c92d6b8']); ++ ++ const refused = await submitted(t, () => Promise.resolve({ status: 'ESME_RINVDSTADR' })); ++ ++ assert.match(refused.sent.err?.message ?? '', /ESME_RINVDSTADR/); ++ }); ++ ++ test('refuses with the retry status when the handler throws, and reports it', async t => { ++ const failures: string[] = []; ++ const smpp = await startServer(t, { onSms: () => { throw new Error('the handler exploded'); } }); ++ ++ smpp.on('session', session => { session.on('sessionError', err => { failures.push(err.message); }); }); ++ ++ const esme = await bound(t, smpp); ++ const sent = await esme.sendSms({ from: '46701113311', message: 'throws', to: '46709771337' }); ++ ++ assert.match(sent.err?.message ?? '', /ESME_RTHROTTLED/); ++ assert.deepEqual(failures, ['the handler exploded']); ++ }); ++ ++ test('keeps the answer sendResp() gave, and reports a contradicting return', async t => { ++ const failures: string[] = []; ++ const smpp = await startServer(t, { ++ onSms: async sms => { ++ await sms.sendResp({ smsId: 'answered-early' }); ++ ++ return { smsId: 'answered-late' }; ++ }, ++ }); ++ ++ smpp.on('session', session => { session.on('sessionError', err => { failures.push(err.message); }); }); ++ ++ const esme = await bound(t, smpp); ++ const sent = await esme.sendSms({ from: '46701113311', message: 'answered twice', to: '46709771337' }); ++ ++ assert.deepEqual(sent.smsIds, ['answered-early']); ++ assert.ok(await waitFor(() => failures.length === 1)); ++ assert.match(failures[0] ?? '', /already answered/); ++ }); ++ ++ test('refuses a delivery with the receiver\'s retry status when a client has no handler', async t => { ++ const smpp = await startServer(t); ++ const esme = await bound(t, smpp); ++ const delivered = await peerOf(smpp).send({ ++ cmdName: 'deliver_sm', ++ params: { destination_addr: '46701113311', short_message: 'nobody home', source_addr: '46709771337' }, ++ }); ++ ++ assert.ok(delivered.pduObj); ++ assert.equal(delivered.pduObj.cmdStatus, 'ESME_RX_T_APPN'); ++ assert.ok(esme); ++ }); ++}); +diff --git a/test/session.test.ts b/test/session.test.ts +index 1f31f0b..713115e 100644 +--- a/test/session.test.ts ++++ b/test/session.test.ts +@@ -3,16 +3,17 @@ import net from 'node:net'; + import test, { describe } from 'node:test'; + import type { Dlr } from '../src/dlr.ts'; + import type { PduObject, PduObjectInput } from '../src/pdu.ts'; ++import type { OnSms } from '../src/session-options.ts'; + import type { Sms } from '../src/sms.ts'; ++import type { SmppClient } from '../src/client.ts'; + import type { ServerOptions, SmppServer } from '../src/server.ts'; + import type { SmppLog } from '../src/log.ts'; + import type { TestContext } from 'node:test'; +-import type { VoidResult } from '../src/result.ts'; + import { DlrMerger } from '../src/dlr-merger.ts'; + import { PduFramer } from '../src/pdu-framer.ts'; + import { ReconnectLoop } from '../src/reconnect-loop.ts'; +-import { Session, bindCommands } from '../src/session.ts'; +-import { checkSessionOptions } from '../src/session-options.ts'; ++import { Session } from '../src/session.ts'; ++import { bindCommands, checkSessionOptions } from '../src/session-options.ts'; + import { client } from '../src/client.ts'; + import { closeAfter, closeListenerAfter } from './teardown.ts'; + import { consts } from '../src/defs/constants.ts'; +@@ -44,7 +45,7 @@ async function connect( + ) { + const connected = await client({ port: smpp.port, ...options }); + +- if (connected.session) closeAfter(t, connected.session); ++ if (connected.client) closeAfter(t, connected.client); + + return connected; + } +@@ -53,6 +54,43 @@ function once(register: (resolve: (value: T) => void) => void): Promise { + return new Promise(resolve => { register(resolve); }); + } + ++/** The bound client, asserted. */ ++async function bound( ++ t: TestContext, ++ smpp: SmppServer, ++ options: Parameters[0] = {}, ++): Promise { ++ const { err, client: esme } = await connect(t, smpp, options); ++ ++ assert.equal(err, undefined); ++ assert.ok(esme); ++ ++ return esme; ++} ++ ++/** The client's bound session, asserted. */ ++function linkOf(esme: SmppClient): Session { ++ const session = esme.session; ++ ++ assert.ok(session, 'the client has no bound session'); ++ ++ return session; ++} ++ ++/** A handler that hands the message to the test and answers ESME_ROK once the test lets it. */ ++function handedOver(answer: (sms: Sms) => Promise = () => Promise.resolve()): { onSms: OnSms; sms: Promise } { ++ const handed: { resolve?: (sms: Sms) => void } = {}; ++ const sms = once(resolve => { handed.resolve = resolve; }); ++ ++ return { ++ onSms: async received => { ++ handed.resolve?.(received); ++ await answer(received); ++ }, ++ sms, ++ }; ++} ++ + function delay(ms: number): Promise { + return new Promise(resolve => { setTimeout(resolve, ms); }); + } +@@ -225,33 +263,32 @@ async function bindRaw(t: TestContext, smpp: SmppServer, interfaceVersion: numbe + describe('bind', () => { + test('binds and unbinds against a server with no auth', async t => { + const smpp = await startServer(t); +- const { err, session } = await connect(t, smpp); ++ const { err, client: esme } = await connect(t, smpp); + + assert.equal(err, undefined); +- assert.ok(session); +- assert.equal(session.boundAs, 'transceiver'); ++ assert.ok(esme); ++ assert.equal(linkOf(esme).boundAs, 'transceiver'); + +- assert.deepEqual(await session.unbind(), {}); ++ assert.deepEqual(await esme.unbind(), {}); + }); + +- // Plenty of SMSCs drop the connection on unbind instead of answering it. + test('takes a close that follows our unbind as a clean unbind', async t => { + const peer = await smscPeer(t, { dropOn: 'unbind' }); +- const { session } = await client({ port: peer.port, responseTimeout: 2000 }); ++ const { client: esme } = await client({ port: peer.port, responseTimeout: 2000 }); + +- assert.ok(session); +- closeAfter(t, session); +- assert.deepEqual(await session.unbind(), {}); ++ assert.ok(esme); ++ closeAfter(t, esme); ++ assert.deepEqual(await esme.unbind(), {}); + }); + + test('still reports a close that lands on another in-flight request', async t => { + const peer = await smscPeer(t, { dropOn: 'enquire_link' }); +- const { session } = await client({ port: peer.port, responseTimeout: 2000 }); ++ const { client: esme } = await client({ port: peer.port, responseTimeout: 2000 }); + +- assert.ok(session); +- closeAfter(t, session); ++ assert.ok(esme); ++ closeAfter(t, esme); + +- const sent = await session.send({ cmdName: 'enquire_link' }); ++ const sent = await esme.send({ cmdName: 'enquire_link' }); + + assert.ok(sent.err instanceof Error); + assert.match(sent.err.message, /may have accepted.*Session closed before a response arrived/); +@@ -259,11 +296,11 @@ describe('bind', () => { + + test('reports an unbind the peer left unanswered on a link that stays up', async t => { + const peer = await smscPeer(t); +- const { session } = await client({ port: peer.port, responseTimeout: 150 }); ++ const { client: esme } = await client({ port: peer.port, responseTimeout: 150 }); + +- assert.ok(session); +- closeAfter(t, session); +- assert.ok((await session.unbind()).err instanceof Error); ++ assert.ok(esme); ++ closeAfter(t, esme); ++ assert.ok((await esme.unbind()).err instanceof Error); + }); + + test('reports the resolved port when 0 was requested', async t => { +@@ -276,10 +313,10 @@ describe('bind', () => { + const smpp = await startServer(t, { + authenticate: ({ password, systemId }) => systemId === 'foo' && password === 'bar', + }); +- const { err, session } = await connect(t, smpp); ++ const { err, client: esme } = await connect(t, smpp); + + assert.ok(err instanceof Error); +- assert.equal(session, undefined); ++ assert.equal(esme, undefined); + }); + + test('accepts the right credentials and attaches userData', async t => { +@@ -289,14 +326,14 @@ describe('bind', () => { + : false, + }); + const serverSession = once(resolve => smpp.on('session', resolve)); +- const { err, session } = await connect(t, smpp, { password: 'bar', username: 'foo' }); ++ const { err, client: esme } = await connect(t, smpp, { password: 'bar', username: 'foo' }); + + assert.equal(err, undefined); +- assert.ok(session); ++ assert.ok(esme); + +- const bound = await serverSession; ++ const accepted = await serverSession; + +- assert.deepEqual(bound.userData, { userId: 123 }); ++ assert.deepEqual(accepted.userData, { userId: 123 }); + }); + + test('answers a non-bind command from an unbound peer with ESME_RINVBNDSTS', async t => { +@@ -363,8 +400,8 @@ describe('bind', () => { + }); + }); + +- const { session: byDefault } = await connect(t, smpp); +- const { session: asFive } = await connect(t, smpp, { interfaceVersion: 0x50 }); ++ const { client: byDefault } = await connect(t, smpp); ++ const { client: asFive } = await connect(t, smpp, { interfaceVersion: 0x50 }); + + assert.ok(byDefault); + assert.ok(asFive); +@@ -441,11 +478,11 @@ describe('bind', () => { + test('records the version the SMSC declared in its bind response', async t => { + const smpp = await startServer(t, { interfaceVersion: 0x50 }); + +- const { session } = await connect(t, smpp); ++ const { client: esme } = await connect(t, smpp); + +- assert.ok(session); +- assert.equal(session.peerInterfaceVersion, 0x50); +- assert.ok(session.acceptsOptionalParams()); ++ assert.ok(esme); ++ assert.equal(linkOf(esme).peerInterfaceVersion, 0x50); ++ assert.ok(linkOf(esme).acceptsOptionalParams()); + }); + + test('records a hand-wired bind through bound(), and nothing else writes it', t => { +@@ -480,12 +517,12 @@ describe('bind', () => { + // The spec: an absent sc_interface_version means the SMSC supports no optional parameters. + test('takes an SMSC that declares no version as older than 3.4', async t => { + const peer = await smscPeer(t); +- const { session } = await client({ port: peer.port }); ++ const { client: esme } = await client({ port: peer.port }); + +- assert.ok(session); +- closeAfter(t, session); +- assert.equal(session.peerInterfaceVersion, 0x00); +- assert.equal(session.acceptsOptionalParams(), false); ++ assert.ok(esme); ++ closeAfter(t, esme); ++ assert.equal(linkOf(esme).peerInterfaceVersion, 0x00); ++ assert.equal(linkOf(esme).acceptsOptionalParams(), false); + }); + }); + +@@ -493,11 +530,11 @@ describe('bind direction', () => { + // A receiver-bound ESME sends no submit_sm and a transmitter-bound one is sent no deliver_sm. + test('refuses a submit_sm from a peer that bound as a receiver', async t => { + const smpp = await startServer(t); +- const { session } = await connect(t, smpp, { bindType: 'receiver' }); ++ const { client: esme } = await connect(t, smpp, { bindType: 'receiver' }); + +- assert.ok(session); ++ assert.ok(esme); + +- const sent = await session.send({ ++ const sent = await esme.send({ + cmdName: 'submit_sm', + params: { destination_addr: '46709771337', short_message: 'nope', source_addr: '46701113311' }, + }); +@@ -506,17 +543,11 @@ describe('bind direction', () => { + assert.equal(sent.pduObj.cmdStatus, 'ESME_RINVBNDSTS'); + }); + +- test('refuses sendSms() on a receiver-bound session before it reaches the wire', async t => { +- const smpp = await startServer(t); ++ test('refuses sendSms() on a receiver-bound client before it reaches the wire', async t => { + const arrived: Sms[] = []; +- +- smpp.on('session', peer => peer.on('sms', sms => arrived.push(sms))); +- +- const { session } = await connect(t, smpp, { bindType: 'receiver' }); +- +- assert.ok(session); +- +- const sent = await session.sendSms({ from: '46701113311', message: 'nope', to: '46709771337' }); ++ const smpp = await startServer(t, { onSms: sms => { arrived.push(sms); } }); ++ const esme = await bound(t, smpp, { bindType: 'receiver' }); ++ const sent = await esme.sendSms({ from: '46701113311', message: 'nope', to: '46709771337' }); + + assert.ok(sent.err instanceof Error); + assert.match(sent.err.message, /receiver-bound/); +@@ -527,9 +558,9 @@ describe('bind direction', () => { + test('refuses a deliver_sm sent to a peer that bound as a transmitter', async t => { + const smpp = await startServer(t); + const bound = once(resolve => { smpp.on('session', resolve); }); +- const { session } = await connect(t, smpp, { bindType: 'transmitter' }); ++ const { client: esme } = await connect(t, smpp, { bindType: 'transmitter' }); + +- assert.ok(session); ++ assert.ok(esme); + + const peer = await bound; + const sent = await peer.send({ +@@ -542,21 +573,12 @@ describe('bind direction', () => { + }); + + test('refuses sendDlr() to a transmitter-bound peer before it reaches the wire', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', peer => peer.on('sms', resolve)); +- }); +- const { session } = await connect(t, smpp, { bindType: 'transmitter' }); +- +- assert.ok(session); +- ++ const handed = handedOver(); ++ const smpp = await startServer(t, { onSms: handed.onSms }); ++ const esme = await bound(t, smpp, { bindType: 'transmitter' }); + const [sms] = await Promise.all([ +- incoming.then(async received => { +- await received.sendResp(); +- +- return received; +- }), +- session.sendSms({ dlr: true, from: '46701113311', message: 'one way', to: '46709771337' }), ++ handed.sms, ++ esme.sendSms({ dlr: true, from: '46701113311', message: 'one way', to: '46709771337' }), + ]); + const report = await sms.sendDlr(); + +@@ -564,13 +586,12 @@ describe('bind direction', () => { + assert.match(report.err.message, /transmitter-bound/); + }); + +- // data_sm carries a message either way, so which end this is decides which way it may travel. + test('refuses a data_sm sent to a peer that bound as a transmitter', async t => { + const smpp = await startServer(t); + const bound = once(resolve => { smpp.on('session', resolve); }); +- const { session } = await connect(t, smpp, { bindType: 'transmitter' }); ++ const { client: esme } = await connect(t, smpp, { bindType: 'transmitter' }); + +- assert.ok(session); ++ assert.ok(esme); + + const peer = await bound; + const sent = await peer.send({ +@@ -585,15 +606,13 @@ describe('bind direction', () => { + }); + + test('refuses a data_sm from a receiver-bound peer, and carries one from a transmitter', async t => { +- const smpp = await startServer(t); +- +- smpp.on('session', peer => peer.on('sms', sms => void sms.sendResp())); ++ const smpp = await startServer(t, { onSms: () => undefined }); + + const receiving = await connect(t, smpp, { bindType: 'receiver' }); + +- assert.ok(receiving.session); ++ assert.ok(receiving.client); + +- const refused = await receiving.session.send({ ++ const refused = await receiving.client.send({ + cmdName: 'data_sm', + params: { destination_addr: '46709771337', source_addr: '46701113311' }, + tlvs: { message_payload: { tagValue: Buffer.from('nope') } }, +@@ -604,9 +623,9 @@ describe('bind direction', () => { + + const sending = await connect(t, smpp, { bindType: 'transmitter' }); + +- assert.ok(sending.session); ++ assert.ok(sending.client); + +- const carried = await sending.session.send({ ++ const carried = await sending.client.send({ + cmdName: 'data_sm', + params: { destination_addr: '46709771337', source_addr: '46701113311' }, + tlvs: { message_payload: { tagValue: Buffer.from('a submission the bind carries') } }, +@@ -619,24 +638,17 @@ describe('bind direction', () => { + + describe('sending', () => { + test('delivers a simple SMS with the sender TON derived from the address', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); +- const { session } = await connect(t, smpp); +- +- assert.ok(session); ++ const handed = handedOver(async received => { ++ const refused = await received.sendResp({ smsId: '' }); + ++ assert.ok(refused.err instanceof Error); ++ await received.sendResp({ smsId: 'fixed-id' }); ++ }); ++ const smpp = await startServer(t, { onSms: handed.onSms }); ++ const esme = await bound(t, smpp); + const [sms, sent] = await Promise.all([ +- incoming.then(async received => { +- const refused = await received.sendResp({ smsId: '' }); +- +- assert.ok(refused.err instanceof Error); +- await received.sendResp({ smsId: 'fixed-id' }); +- +- return received; +- }), +- session.sendSms({ from: 'MyBrand', message: 'hello world', to: '46709771337' }), ++ handed.sms, ++ esme.sendSms({ from: 'MyBrand', message: 'hello world', to: '46709771337' }), + ]); + + assert.equal(sms.from, 'MyBrand'); +@@ -656,22 +668,13 @@ describe('sending', () => { + }); + + test('reassembles a long SMS and answers every segment', async t => { +- const smpp = await startServer(t); ++ const handed = handedOver(); ++ const smpp = await startServer(t, { onSms: handed.onSms }); + const message = 'Lorem ipsum dolor sit amet, '.repeat(20); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); +- const { session } = await connect(t, smpp); +- +- assert.ok(session); +- ++ const esme = await bound(t, smpp); + const [sms, sent] = await Promise.all([ +- incoming.then(async received => { +- await received.sendResp(); +- +- return received; +- }), +- session.sendSms({ from: '46701113311', message, to: '46709771337' }), ++ handed.sms, ++ esme.sendSms({ from: '46701113311', message, to: '46709771337' }), + ]); + + assert.equal(sms.message, message); +@@ -682,22 +685,13 @@ describe('sending', () => { + }); + + test('carries a UCS2 message through unchanged', async t => { +- const smpp = await startServer(t); ++ const handed = handedOver(); ++ const smpp = await startServer(t, { onSms: handed.onSms }); + const message = 'räksmörgås تست 一'; +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); +- const { session } = await connect(t, smpp); +- +- assert.ok(session); +- ++ const esme = await bound(t, smpp); + const [sms] = await Promise.all([ +- incoming.then(async received => { +- await received.sendResp(); +- +- return received; +- }), +- session.sendSms({ from: '46701113311', message, to: '46709771337' }), ++ handed.sms, ++ esme.sendSms({ from: '46701113311', message, to: '46709771337' }), + ]); + + assert.equal(sms.message, message); +@@ -705,21 +699,12 @@ describe('sending', () => { + }); + + test('marks a flash message without losing the UCS2 alphabet', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); +- const { session } = await connect(t, smpp); +- +- assert.ok(session); +- ++ const handed = handedOver(); ++ const smpp = await startServer(t, { onSms: handed.onSms }); ++ const esme = await bound(t, smpp); + const [sms] = await Promise.all([ +- incoming.then(async received => { +- await received.sendResp(); +- +- return received; +- }), +- session.sendSms({ flash: true, from: '46701113311', message: 'تست', to: '46709771337' }), ++ handed.sms, ++ esme.sendSms({ flash: true, from: '46701113311', message: 'تست', to: '46709771337' }), + ]); + + // 0.4.0 forced data_coding to 0x10, which discards UCS2 and mangles the message. +@@ -729,21 +714,12 @@ describe('sending', () => { + }); + + test('puts the address TON and NPI the caller chose on the wire', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', peer => peer.on('sms', resolve)); +- }); +- const { session } = await connect(t, smpp); +- +- assert.ok(session); +- ++ const handed = handedOver(); ++ const smpp = await startServer(t, { onSms: handed.onSms }); ++ const esme = await bound(t, smpp); + const [sms] = await Promise.all([ +- incoming.then(async received => { +- await received.sendResp(); +- +- return received; +- }), +- session.sendSms({ ++ handed.sms, ++ esme.sendSms({ + destinationAddrNpi: consts.NPI.ISDN, + destinationAddrTon: consts.TON.NATIONAL, + from: '46701113311', +@@ -753,7 +729,6 @@ describe('sending', () => { + to: '46709771337', + }), + ]); +- + const params = sms.pduObjs[0]?.params; + + assert.ok(params); +@@ -762,26 +737,40 @@ describe('sending', () => { + assert.equal(params.source_addr_npi, consts.NPI.PRIVATE); + assert.equal(params.source_addr_ton, consts.TON.ABBREVIATED); + }); ++ + }); + + describe('receiving', () => { ++ type Inbound = { esme: SmppClient; peer: Session; sms: Promise }; ++ ++ /** A client whose handler hands each message to the test, answering on the test's terms. */ + async function inbound( + t: TestContext, + options: ServerOptions = {}, +- ): Promise<{ peer: Session; session: Session }> { ++ answer: (sms: Sms) => Promise = () => Promise.resolve(), ++ ): Promise { + const smpp = await startServer(t, options); ++ const handed = handedOver(answer); ++ const accepted = once(resolve => { smpp.on('session', resolve); }); ++ const { err, client: esme } = await connect(t, smpp, { onSms: handed.onSms }); + +- const bound = once(resolve => { smpp.on('session', resolve); }); +- const { session } = await connect(t, smpp); ++ assert.equal(err, undefined); ++ assert.ok(esme); ++ ++ return { esme, peer: await accepted, sms: handed.sms }; ++ } + +- assert.ok(session); ++ /** The same with no handler on the client at all. */ ++ async function inboundUnhandled(t: TestContext): Promise<{ esme: SmppClient; peer: Session }> { ++ const smpp = await startServer(t); ++ const accepted = once(resolve => { smpp.on('session', resolve); }); ++ const esme = await bound(t, smpp); + +- return { peer: await bound, session }; ++ return { esme, peer: await accepted }; + } + + test('hands a client a deliver_sm that is not a delivery receipt', async t => { +- const { peer, session } = await inbound(t); +- const incoming = once(resolve => { session.on('sms', resolve); }); ++ const { peer, sms: incoming } = await inbound(t, {}, received => received.sendResp({ smsId: 'inbound-id' })); + const delivered = peer.send({ + cmdName: 'deliver_sm', + params: { +@@ -797,8 +786,6 @@ describe('receiving', () => { + assert.equal(sms.to, '46709771337'); + assert.equal(sms.message, 'inbound hello'); + +- await sms.sendResp({ smsId: 'inbound-id' }); +- + const answered = await delivered; + + assert.ok(answered.pduObj); +@@ -811,10 +798,8 @@ describe('receiving', () => { + assert.equal(sms.smsId, 'inbound-id', 'the id the application chose is still its own handle'); + }); + +- // SMPP 3.4 5.3.2.32: up to 64 KB of body in a TLV, with sm_length 0 and short_message empty. + test('reads an inbound message the peer carried in message_payload', async t => { +- const { peer, session } = await inbound(t); +- const incoming = once(resolve => { session.on('sms', resolve); }); ++ const { peer, sms: incoming } = await inbound(t); + const text = 'the whole body, carried in the TLV'; + const delivered = peer.send({ + cmdName: 'deliver_sm', +@@ -832,8 +817,6 @@ describe('receiving', () => { + assert.equal(sms.from, '46701113311'); + assert.equal(sms.to, '46709771337'); + +- await sms.sendResp(); +- + const answered = await delivered; + + assert.ok(answered.pduObj); +@@ -841,9 +824,8 @@ describe('receiving', () => { + }); + + test('hands a client a data_sm carrying a message as an sms, answered data_sm_resp', async t => { +- const { peer, session } = await inbound(t); +- const incoming = once(resolve => { session.on('sms', resolve); }); + const smsId = '0199e0f1-6c31-7a44-9d02-4b7e51c3a806'; ++ const { peer, sms: incoming } = await inbound(t, {}, received => received.sendResp({ smsId })); + const delivered = peer.send({ + cmdName: 'data_sm', + params: { destination_addr: '46709771337', source_addr: '46701113311' }, +@@ -855,8 +837,6 @@ describe('receiving', () => { + assert.equal(sms.message, 'a message carried on the data command'); + assert.equal(sms.from, '46701113311'); + +- await sms.sendResp({ smsId }); +- + const answered = await delivered; + + assert.ok(answered.pduObj); +@@ -867,13 +847,10 @@ describe('receiving', () => { + }); + + test('hands a client a receipt carried on data_sm as a dlr', async t => { +- const { peer, session } = await inbound(t); +- const reported = once(resolve => { session.on('dlr', resolve); }); +- const smsId = '0199e0f1-b8a2-7f19-8c63-2d5041fb9e77'; + let messages = 0; +- +- session.on('sms', () => { messages++; }); +- ++ const { esme, peer } = await inbound(t, {}, () => { messages++; return Promise.resolve(); }); ++ const reported = once(resolve => { esme.on('dlr', resolve); }); ++ const smsId = '0199e0f1-b8a2-7f19-8c63-2d5041fb9e77'; + const delivered = peer.send({ + cmdName: 'data_sm', + params: { +@@ -901,26 +878,16 @@ describe('receiving', () => { + assert.equal(answered.pduObj.cmdStatus, 'ESME_ROK'); + }); + +- // At the SMSC end an inbound data_sm is a submission, so nothing in one reports on our own sends. + test('reads a receipt-shaped data_sm submitted to a server as the message it is', async t => { +- const smpp = await startServer(t); + const body = 'id:0199e0f2-2d15-7b83-a4c1-6e90b7d2f345 stat:DELIVRD err:000 text:'; + const messages: Sms[] = []; + const reports: Dlr[] = []; ++ const smpp = await startServer(t, { onSms: sms => { messages.push(sms); } }); + +- smpp.on('session', peer => { +- peer.on('dlr', dlr => { reports.push(dlr); }); +- peer.on('sms', sms => { +- messages.push(sms); +- void sms.sendResp(); +- }); +- }); +- +- const { session } = await connect(t, smpp, { bindType: 'transmitter' }); ++ smpp.on('session', peer => { peer.on('dlr', dlr => { reports.push(dlr); }); }); + +- assert.ok(session); +- +- const submitted = await session.send({ ++ const esme = await bound(t, smpp, { bindType: 'transmitter' }); ++ const submitted = await esme.send({ + cmdName: 'data_sm', + params: { + destination_addr: '46709771337', +@@ -937,19 +904,15 @@ describe('receiving', () => { + assert.deepEqual(reports, [], 'an ESME submitting is never the network reporting'); + }); + +- // The refusal a submission gets is the one submit_sm_resp defines, whichever command carried it. + test('refuses a data_sm segment a server has no room for with the submit code', async t => { + // The two addresses and the objects are 1322 octets, so the 6-octet UDH and its text are what overrun 1330. + const smpp = await startServer(t, { maxOctets: 1330 }); +- const { session } = await connect(t, smpp, { bindType: 'transmitter' }); +- +- assert.ok(session); +- ++ const esme = await bound(t, smpp, { bindType: 'transmitter' }); + const segment = splitMessage('one of two, too big to hold. '.repeat(12), { reference: 0x5C })[0]; + + assert.ok(segment); + +- const refused = await session.send({ ++ const refused = await esme.send({ + cmdName: 'data_sm', + params: { + destination_addr: '46709771337', +@@ -965,8 +928,7 @@ describe('receiving', () => { + }); + + test('reassembles a concatenated message whose segments arrived in message_payload', async t => { +- const { peer, session } = await inbound(t); +- const incoming = once(resolve => { session.on('sms', resolve); }); ++ const { peer, sms: incoming } = await inbound(t); + const text = 'A body in the TLV is still numbered by its UDH. '.repeat(6); + const segments = splitMessage(text, { reference: 0x3B }); + +@@ -1005,12 +967,9 @@ describe('receiving', () => { + }); + + test('hands a client a report as a dlr rather than as an sms', async t => { +- const { peer, session } = await inbound(t); +- const reported = once(resolve => { session.on('dlr', resolve); }); + let messages = 0; +- +- session.on('sms', () => { messages++; }); +- ++ const { esme, peer } = await inbound(t, {}, () => { messages++; return Promise.resolve(); }); ++ const reported = once(resolve => { esme.on('dlr', resolve); }); + const delivered = peer.send({ + cmdName: 'deliver_sm', + params: { +@@ -1031,7 +990,7 @@ describe('receiving', () => { + assert.ok(answered.pduObj); + assert.equal(answered.pduObj.cmdName, 'deliver_sm_resp'); + +- const notified = once(resolve => { session.on('dlr', resolve); }); ++ const notified = once(resolve => { esme.on('dlr', resolve); }); + const notification = peer.send({ + cmdName: 'deliver_sm', + params: { +@@ -1054,11 +1013,10 @@ describe('receiving', () => { + assert.equal(answeredNotification.pduObj.cmdName, 'deliver_sm_resp'); + }); + +- // SMPPSim's receipt for a UCS2 message inherits its data_coding and writes the body as text. + test('parses a receipt written as text under a data_coding that says UCS2', async t => { +- const { peer, session } = await inbound(t); ++ const { esme, peer } = await inboundUnhandled(t); + const reported = once<{ dlr: Dlr; pduObj: PduObject }>(resolve => { +- session.on('dlr', (dlr, pduObj) => { resolve({ dlr, pduObj }); }); ++ esme.on('dlr', (dlr, pduObj) => { resolve({ dlr, pduObj }); }); + }); + const smsId = '01a072f9-30f2-71b0-87cd-f5032df3a8e0'; + const body = `id:${smsId} sub:001 dlvrd:001 submit date:2509051430 done date:2509051431 stat:DELIVRD err:000 text:`; +@@ -1083,10 +1041,9 @@ describe('receiving', () => { + assert.ok((await delivered).pduObj); + }); + +- test('reassembles a multipart inbound SMS before the sms event', async t => { ++ test('reassembles a multipart inbound SMS before it reaches the handler', async t => { + const message = 'Inbound lorem ipsum dolor sit amet consectetur, '.repeat(6); +- const { peer, session } = await inbound(t); +- const incoming = once(resolve => { session.on('sms', resolve); }); ++ const { peer, sms: incoming } = await inbound(t); + const segments = splitMessage(message, { reference: 42 }); + + assert.equal(segments.length, 2); +@@ -1124,18 +1081,15 @@ describe('receiving', () => { + } + + test('answers every sar_* segment on arrival and hands the application one message', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { smpp.on('session', peer => peer.on('sms', resolve)); }); +- const { session } = await connect(t, smpp, { bindType: 'transmitter', responseTimeout: 1000 }); +- +- assert.ok(session); +- ++ const handed = handedOver(); ++ const smpp = await startServer(t, { onSms: handed.onSms }); ++ const esme = await bound(t, smpp, { bindType: 'transmitter', responseTimeout: 1000 }); + const parts = ['sar one, ', 'sar two, ', 'sar three']; + const answers: PduObject[] = []; + + // A 16-bit reference no 8-bit UDH could carry, which is the width the TLV exists for. + for (const [index, part] of parts.entries()) { +- const sent = await session.send({ ++ const sent = await esme.send({ + cmdName: 'submit_sm', + params: { + destination_addr: '46709771337', +@@ -1149,7 +1103,7 @@ describe('receiving', () => { + answers.push(sent.pduObj); + } + +- const sms = await raceWithin(2000, incoming); ++ const sms = await raceWithin(2000, handed.sms); + + assert.ok(sms, 'the sar_* TLVs tie the three submissions into one message'); + assert.equal(sms.message, parts.join('')); +@@ -1161,8 +1115,7 @@ describe('receiving', () => { + }); + + test('joins sar_* segments in the order they number themselves', async t => { +- const { peer, session } = await inbound(t, { responseTimeout: 1000 }); +- const incoming = once(resolve => { session.on('sms', resolve); }); ++ const { peer, sms: incoming } = await inbound(t, { responseTimeout: 1000 }); + const parts = ['first ', 'second ', 'third']; + + for (const index of [2, 0, 1]) { +@@ -1187,8 +1140,7 @@ describe('receiving', () => { + }); + + test('reassembles a sar_* segment whose body is in message_payload', async t => { +- const { peer, session } = await inbound(t, { responseTimeout: 1000 }); +- const incoming = once(resolve => { session.on('sms', resolve); }); ++ const { peer, sms: incoming } = await inbound(t, { responseTimeout: 1000 }); + const parts = ['in the mandatory field, ', 'and in the TLV']; + + for (const [index, part] of parts.entries()) { +@@ -1223,11 +1175,8 @@ describe('receiving', () => { + + // The UDH reference is 8 bits and sar_msg_ref_num is 16, so the same number is two messages. + test('keeps a UDH group and a sar_* group sharing a reference apart', async t => { +- const { peer, session } = await inbound(t, { responseTimeout: 1000 }); + const messages: Sms[] = []; +- +- session.on('sms', sms => { messages.push(sms); }); +- ++ const { peer } = await inbound(t, { responseTimeout: 1000 }, sms => { messages.push(sms); return Promise.resolve(); }); + const udhText = 'the message numbered by its user data header. '.repeat(5); + const udhSegments = splitMessage(udhText, { reference: 5 }); + const sarParts = ['the message numbered by ', 'its optional parameters']; +@@ -1271,8 +1220,7 @@ describe('receiving', () => { + + // Nothing compares the two references: each spelling counts in a space of its own. + test('groups a segment carrying both spellings by its UDH', async t => { +- const { peer, session } = await inbound(t, { responseTimeout: 1000 }); +- const incoming = once(resolve => { session.on('sms', resolve); }); ++ const { peer, sms: incoming } = await inbound(t, { responseTimeout: 1000 }); + const message = 'both spellings on every segment of it, and the UDH decides. '.repeat(4); + const segments = splitMessage(message, { reference: 7 }); + +@@ -1301,12 +1249,11 @@ describe('receiving', () => { + }); + + test('reads a receipt carrying sar_* fields as a dlr, never as a segment', async t => { +- const { peer, session } = await inbound(t, { responseTimeout: 1000 }); + const reports: Dlr[] = []; + let messages = 0; ++ const { esme, peer } = await inbound(t, { responseTimeout: 1000 }, () => { messages++; return Promise.resolve(); }); + +- session.on('dlr', dlr => { reports.push(dlr); }); +- session.on('sms', () => { messages++; }); ++ esme.on('dlr', dlr => { reports.push(dlr); }); + + const marked = '0199e1a4-6c3f-7d21-9a80-5b1e2f7c4d63'; + const unmarked = '0199e1a4-b70e-7c55-8f42-9d3a1c86e70b'; +@@ -1350,25 +1297,15 @@ describe('receiving', () => { + + describe('delivery reports', () => { + test('reaches the sender as a dlr event', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); +- const { session } = await connect(t, smpp); +- +- assert.ok(session); +- ++ const handed = handedOver(received => received.sendResp({ smsId: 'dlr-id' })); ++ const smpp = await startServer(t, { onSms: handed.onSms }); ++ const esme = await bound(t, smpp); + const dlr = once<[Dlr, PduObject]>(resolve => { +- session.on('dlr', (report, pduObj) => { resolve([report, pduObj]); }); ++ esme.on('dlr', (report, pduObj) => { resolve([report, pduObj]); }); + }); +- + const [sms] = await Promise.all([ +- incoming.then(async received => { +- await received.sendResp({ smsId: 'dlr-id' }); +- +- return received; +- }), +- session.sendSms({ dlr: true, from: '46701113311', message: 'hi', to: '46709771337' }), ++ handed.sms, ++ esme.sendSms({ dlr: true, from: '46701113311', message: 'hi', to: '46709771337' }), + ]); + + assert.ok(sms.dlr); +@@ -1383,30 +1320,20 @@ describe('delivery reports', () => { + }); + + test('reports a failure with the spec status code', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); +- const { session } = await connect(t, smpp); +- +- assert.ok(session); +- +- const dlr = once<{ statusMsg: string }>(resolve => { session.on('dlr', resolve); }); ++ const handed = handedOver(received => received.sendResp({ smsId: 'fail-id' })); ++ const smpp = await startServer(t, { onSms: handed.onSms }); ++ const esme = await bound(t, smpp); ++ const dlr = once<{ statusMsg: string }>(resolve => { esme.on('dlr', resolve); }); + const raw = once(resolve => { +- session.on('incomingPduObj', pduObj => { ++ linkOf(esme).on('incomingPduObj', pduObj => { + const message = pduObj.params.short_message; + + if (typeof message === 'string') resolve(message); + }); + }); +- + const [sms] = await Promise.all([ +- incoming.then(async received => { +- await received.sendResp({ smsId: 'fail-id' }); +- +- return received; +- }), +- session.sendSms({ dlr: true, from: '46701113311', message: 'hi', to: '46709771337' }), ++ handed.sms, ++ esme.sendSms({ dlr: true, from: '46701113311', message: 'hi', to: '46709771337' }), + ]); + + await sms.sendDlr('UNDELIVERABLE'); +@@ -1417,10 +1344,8 @@ describe('delivery reports', () => { + }); + + test('sends a text-only receipt to a peer that declared less than 3.4', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); ++ const handed = handedOver(); ++ const smpp = await startServer(t, { onSms: handed.onSms }); + const peer = rawPeer(t, smpp.port); + + peer.write(bindOf(0x33)); +@@ -1438,9 +1363,8 @@ describe('delivery reports', () => { + seqNr: 2, + }); + +- const sms = await incoming; ++ const sms = await handed.sms; + +- await sms.sendResp(); + await peer.next(); + + // A raw peer answers no deliver_sm, so this only settles once the session closes. +@@ -1454,37 +1378,30 @@ describe('delivery reports', () => { + }); + + test('merges nothing for a message that asked for no receipt', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); +- const { session } = await connect(t, smpp); +- +- assert.ok(session); +- ++ const handed = handedOver(); ++ const smpp = await startServer(t, { onSms: handed.onSms }); ++ const esme = await bound(t, smpp); + const perSegment: string[] = []; + let merged = 0; + +- session.on('dlr', dlr => perSegment.push(dlr.smsId ?? '')); +- session.on('messageDlr', () => { merged++; }); ++ esme.on('dlr', dlr => perSegment.push(dlr.smsId ?? '')); ++ esme.on('messageDlr', () => { merged++; }); + + const [sms] = await Promise.all([ +- incoming.then(async received => { +- await received.sendResp(); +- +- return received; +- }), +- session.sendSms({ from: '46701113311', message: 'x'.repeat(400), to: '46709771337' }), ++ handed.sms, ++ esme.sendSms({ from: '46701113311', message: 'x'.repeat(400), to: '46709771337' }), + ]); + + await sms.sendDlr(); + ++ assert.ok(await waitFor(() => perSegment.length === 3)); + assert.deepEqual(perSegment, [1, 2, 3].map(part => `${sms.smsId}-${String(part)}`)); + assert.equal(merged, 0); + }); ++ + }); + +-describe('a session captured from Kannel', () => { ++describe('a esme captured from Kannel', () => { + // Four parts of one message, esm_class 0x43 — the UDH indicator combined with store-and-forward, + // which 0.4.0 originally compared with === 0x40 and missed. + const parts = [ +@@ -1497,10 +1414,9 @@ describe('a session captured from Kannel', () => { + const expected = 'Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry\'s standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum'; + + async function replay(t: TestContext, order: number[]): Promise { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); +- }); ++ const handed = handedOver(); ++ const smpp = await startServer(t, { onSms: handed.onSms }); ++ const incoming = handed.sms; + + const sock = net.connect({ port: smpp.port }, () => { + sock.write(Buffer.from('0000002100000009000000000000002f666f6f0062617200736d70700034000000', 'hex')); +@@ -1522,11 +1438,7 @@ describe('a session captured from Kannel', () => { + } + }); + +- const sms = await incoming; +- +- await sms.sendResp(); +- +- return sms; ++ return incoming; + } + + test('reassembles four segments arriving in order', async t => { +@@ -1558,12 +1470,12 @@ describe('robustness', () => { + + test('leaves an alert_notification and an outbind unanswered, since SMPP names no response', async t => { + const peer = await smscPeer(t); +- const { session } = await client({ port: peer.port }); ++ const { client: esme } = await client({ port: peer.port }); + const errors: Error[] = []; + +- assert.ok(session); +- closeAfter(t, session); +- session.on('sessionError', err => { errors.push(err); }); ++ assert.ok(esme); ++ closeAfter(t, esme); ++ esme.on('sessionError', err => { errors.push(err); }); + peer.writeRaw(pduBytes({ + cmdName: 'alert_notification', + params: { esme_addr: '46709771337', source_addr: '46701113311' }, +@@ -1591,39 +1503,32 @@ describe('robustness', () => { + }); + + test('reports a refused connection rather than throwing', async () => { +- const { err, session } = await client({ port: 1 }); ++ const { err, client: esme } = await client({ port: 1 }); + + assert.ok(err instanceof Error); +- assert.equal(session, undefined); ++ assert.equal(esme, undefined); + }); + + test('keeps at most maxOutstanding requests on the wire', async t => { +- const smpp = await startServer(t); + let concurrent = 0; + let peak = 0; +- +- smpp.on('session', session => { +- session.on('sms', sms => { ++ const smpp = await startServer(t, { ++ onSms: async () => { + concurrent++; + peak = Math.max(peak, concurrent); +- setTimeout(() => { +- concurrent--; +- void sms.sendResp(); +- }, 10); +- }); ++ await delay(10); ++ concurrent--; ++ }, + }); ++ const esme = await bound(t, smpp, { maxOutstanding: 2 }); + +- const { session } = await connect(t, smpp, { maxOutstanding: 2 }); +- +- assert.ok(session); +- +- await Promise.all(Array.from({ length: 8 }, (_, index) => session.sendSms({ ++ await Promise.all(Array.from({ length: 8 }, (_, index) => esme.sendSms({ + from: '46701113311', + message: `message ${String(index)}`, + to: '46709771337', + }))); + +- const long = await session.sendSms({ ++ const long = await esme.sendSms({ + from: '46701113311', + message: 'x'.repeat(500), + to: '46709771337', +@@ -1652,19 +1557,11 @@ describe('robustness', () => { + assert.equal(reported.message, sent.err.message); + }); + +- test('ignores events from the socket it left behind on a reconnect', async t => { +- const smpp = await startServer(t); +- +- smpp.on('session', bound => { +- bound.on('sms', sms => { void sms.sendResp(); }); +- }); +- +- const { session } = await connect(t, smpp, { reconnect: { maxDelay: 50, minDelay: 10 } }); +- +- assert.ok(session); +- +- const reconnected = once(resolve => { session.on('reconnected', () => { resolve(true); }); }); +- const dead = session.sock; ++ test('is live on a new session after a reconnect, and the old one stays closed', async t => { ++ const smpp = await startServer(t, { onSms: () => undefined }); ++ const esme = await bound(t, smpp, { reconnect: { maxDelay: 50, minDelay: 10 } }); ++ const reconnected = once(resolve => { esme.on('reconnected', () => { resolve(true); }); }); ++ const dead = linkOf(esme); + + for (const serverSession of smpp.sessions) { + await serverSession.close(); +@@ -1674,15 +1571,17 @@ describe('robustness', () => { + + let closes = 0; + +- session.on('close', () => { closes++; }); +- dead.emit('close'); ++ esme.on('close', () => { closes++; }); ++ dead.sock.emit('close'); + +- const sent = await session.sendSms({ ++ const sent = await esme.sendSms({ + from: '46701113311', + message: 'still up', + to: '46709771337', + }); + ++ assert.equal(dead.closed, true); ++ assert.notEqual(linkOf(esme), dead); + assert.equal(closes, 0); + assert.equal(sent.err, undefined); + }); +@@ -1690,12 +1589,12 @@ describe('robustness', () => { + test('closes the session when the signal aborts after the bind', async t => { + const smpp = await startServer(t); + const controller = new AbortController(); +- const { err, session } = await connect(t, smpp, { signal: controller.signal }); ++ const { err, client: esme } = await connect(t, smpp, { signal: controller.signal }); + + assert.equal(err, undefined); +- assert.ok(session); ++ assert.ok(esme); + +- const closed = once(resolve => { session.on('close', () => { resolve(true); }); }); ++ const closed = once(resolve => { esme.on('close', () => { resolve(true); }); }); + + controller.abort(); + +@@ -1707,9 +1606,9 @@ describe('robustness', () => { + const smpp = await startServer(t); + + const bound = once(resolve => { smpp.on('session', resolve); }); +- const { session } = await connect(t, smpp); ++ const { client: esme } = await connect(t, smpp); + +- assert.ok(session); ++ assert.ok(esme); + + const peer = await bound; + const controller = new AbortController(); +@@ -1718,7 +1617,7 @@ describe('robustness', () => { + peer.on('incomingPduObj', pduObj => { seen.push(pduObj.cmdName); }); + controller.abort(); + +- const sent = await session.sendSms({ ++ const sent = await esme.sendSms({ + from: '46701113311', + message: 'must never reach the peer', + to: '46709771337', +@@ -1731,22 +1630,17 @@ describe('robustness', () => { + + // The guard sits before the send window, or a full window makes the aborted call queue first. + test('does not wait for a send window slot it will never use', async t => { +- const smpp = await startServer(t); +- +- // The peer answers nothing, so the one slot stays held for the whole test. +- smpp.on('session', session => session.on('sms', () => undefined)); +- +- const { session } = await connect(t, smpp, { maxOutstanding: 1, responseTimeout: 5000 }); +- +- assert.ok(session); ++ // The peer never returns, so the one slot stays held for the whole test. ++ const smpp = await startServer(t, { onSms: () => new Promise(() => undefined) }); ++ const esme = await bound(t, smpp, { maxOutstanding: 1, responseTimeout: 5000 }); + +- void session.sendSms({ from: '46701113311', message: 'holds the slot', to: '46709771337' }); ++ void esme.sendSms({ from: '46701113311', message: 'holds the slot', to: '46709771337' }); + + const controller = new AbortController(); + + controller.abort(); + +- const aborted = await raceWithin(500, session.sendSms({ ++ const aborted = await raceWithin(500, esme.sendSms({ + from: '46701113311', + message: 'must not queue behind the held one', + to: '46709771337', +@@ -1756,28 +1650,28 @@ describe('robustness', () => { + assert.ok(aborted !== false && aborted.err instanceof Error); + }); + +- // A socket the loop opened and never handed over is one leaked per retry, forever. + test('leaves no socket open when coming back up fails', async t => { + const opened: net.Socket[] = []; +- +- function onConnected(): Promise { +- if (opened.length === 1) return Promise.resolve({ err: new Error('bind refused') }); +- +- throw new Error('bind exploded'); +- } +- ++ let attempts = 0; + const loop = new ReconnectLoop({ +- connect: () => { ++ log: silentLog, ++ maxDelay: 10, ++ minDelay: 1, ++ onDown: () => undefined, ++ onUp: () => undefined, ++ open: () => { + const sock = new net.Socket(); + + opened.push(sock); ++ attempts++; ++ ++ // A bind that fails takes its socket with it, whether it returned an err or threw. ++ sock.destroy(); + +- return Promise.resolve({ sock }); ++ if (attempts === 1) return Promise.resolve({ err: new Error('bind refused') }); ++ ++ throw new Error('bind exploded'); + }, +- log: silentLog, +- maxDelay: 10, +- minDelay: 1, +- onConnected, + }); + + t.after(() => { +@@ -1794,7 +1688,9 @@ describe('robustness', () => { + && opened[1]?.destroyed === true); + + assert.ok(destroyed, 'a failed setup should leave no socket open'); ++ assert.equal(loop.current(), undefined); + }); ++ + }); + + describe('a PDU the codec cannot read', () => { +@@ -1814,21 +1710,21 @@ describe('a PDU the codec cannot read', () => { + return pduObj; + } + +- async function bound(t: TestContext, options: Parameters[0] = {}) { ++ async function boundToPeer(t: TestContext, options: Parameters[0] = {}) { + const peer = await smscPeer(t); +- const { session } = await client({ port: peer.port, ...options }); ++ const { client: esme } = await client({ port: peer.port, ...options }); + +- assert.ok(session); +- closeAfter(t, session); ++ assert.ok(esme); ++ closeAfter(t, esme); + +- return { peer, session }; ++ return { esme, peer }; + } + + // ukarim/smscsim signs every deliver_sm it sends unprompted with a raw uint32, so about half + // land above SMPP 3.4 4.7.1's ceiling; refusing them cost the link (findings/01-smscsim.md). + test('answers a deliver_sm whose sequence number is above the spec range, and keeps the link', async t => { +- const { peer, session } = await bound(t); +- const reported = once(resolve => { session.on('dlr', resolve); }); ++ const { esme, peer } = await boundToPeer(t); ++ const reported = once(resolve => { esme.on('dlr', resolve); }); + + peer.writeRaw(pduBytes({ cmdName: 'deliver_sm', params: receipt, seqNr: 0x80000001 })); + +@@ -1852,8 +1748,8 @@ describe('a PDU the codec cannot read', () => { + }); + + test('answers an unknown command id with generic_nack ESME_RINVCMDID', async t => { +- const { peer, session } = await bound(t); +- const failed = once(resolve => { session.on('sessionError', resolve); }); ++ const { esme, peer } = await boundToPeer(t); ++ const failed = once(resolve => { esme.on('sessionError', resolve); }); + + peer.writeRaw(withUnknownCmdId({ cmdName: 'enquire_link', seqNr: 9 })); + +@@ -1866,10 +1762,10 @@ describe('a PDU the codec cannot read', () => { + }); + + test('answers a deliver_sm with a truncated TLV stream with ESME_RINVTLVSTREAM', async t => { +- const { peer, session } = await bound(t); ++ const { esme, peer } = await boundToPeer(t); + let reports = 0; + +- session.on('dlr', () => { reports++; }); ++ esme.on('dlr', () => { reports++; }); + peer.writeRaw(truncatedTlv({ cmdName: 'deliver_sm', params: receipt, seqNr: 5 })); + + const answered = await answerTo(peer); +@@ -1885,10 +1781,10 @@ describe('a PDU the codec cannot read', () => { + }); + + test('answers a deliver_sm ending in a bare TLV header with ESME_RINVTLVSTREAM', async t => { +- const { peer, session } = await bound(t); ++ const { esme, peer } = await boundToPeer(t); + let reports = 0; + +- session.on('dlr', () => { reports++; }); ++ esme.on('dlr', () => { reports++; }); + peer.writeRaw(bareTlvHeader({ cmdName: 'deliver_sm', params: receipt, seqNr: 55 })); + + const answered = await answerTo(peer); +@@ -1903,7 +1799,7 @@ describe('a PDU the codec cannot read', () => { + }); + + test('answers a deliver_sm whose body is shorter than it declares with ESME_RINVCMDLEN', async t => { +- const { peer } = await bound(t); ++ const { peer } = await boundToPeer(t); + + peer.writeRaw(shortened({ cmdName: 'deliver_sm', params: receipt, seqNr: 6 }, 3)); + +@@ -1915,9 +1811,9 @@ describe('a PDU the codec cannot read', () => { + }); + + test('settles the request a response it could not read was answering, and reports it', async t => { +- const { peer, session } = await bound(t, { responseTimeout: 60000 }); +- const failed = once(resolve => { session.on('sessionError', resolve); }); +- const sending = session.sendSms({ from: '46701113311', message: 'hi', to: '46709771337' }); ++ const { esme, peer } = await boundToPeer(t, { responseTimeout: 60000 }); ++ const failed = once(resolve => { esme.on('sessionError', resolve); }); ++ const sending = esme.sendSms({ from: '46701113311', message: 'hi', to: '46709771337' }); + const submitted = await answerTo(peer); + + assert.equal(submitted.cmdName, 'submit_sm'); +@@ -1944,9 +1840,9 @@ describe('a PDU the codec cannot read', () => { + }); + + test('tears the link down when the stream itself cannot be framed', async t => { +- const { peer, session } = await bound(t, { reconnect: false }); +- const failed = once(resolve => { session.on('sessionError', resolve); }); +- const closed = once(resolve => { session.on('close', () => { resolve(true); }); }); ++ const { esme, peer } = await boundToPeer(t, { reconnect: false }); ++ const failed = once(resolve => { esme.on('sessionError', resolve); }); ++ const closed = once(resolve => { esme.on('close', () => { resolve(true); }); }); + + // A command_length below the 16 octet header leaves nothing that can find the next PDU. + peer.writeRaw(Buffer.from('00000004000000150000000000000001', 'hex')); +@@ -1972,96 +1868,77 @@ describe('application hooks that throw or reject', () => { + assert.equal(reported.message, 'authenticate exploded'); + }); + +- test('turns a throwing sms listener into a session error', async t => { +- const smpp = await startServer(t); ++ test('turns a throwing onSms handler into a refusal and a session error', async t => { ++ const smpp = await startServer(t, { onSms: () => { throw new Error('handler exploded'); } }); + const failed = once(resolve => { +- smpp.on('session', session => { +- session.on('sessionError', resolve); +- session.on('sms', () => { throw new Error('listener exploded'); }); +- }); ++ smpp.on('session', session => { session.on('sessionError', resolve); }); + }); +- const { session } = await connect(t, smpp, { responseTimeout: 200 }); +- +- assert.ok(session); +- +- const sent = await session.sendSms({ ++ const esme = await bound(t, smpp, { responseTimeout: 200 }); ++ const sent = await esme.sendSms({ + from: '46701113311', +- message: 'blows up the listener', ++ message: 'blows up the handler', + to: '46709771337', + }); + const reported = await raceWithin(500, failed); + +- assert.ok(sent.err instanceof Error); +- assert.ok(reported instanceof Error, 'a throwing sms listener should reach the session'); +- assert.equal(reported.message, 'listener exploded'); ++ assert.match(sent.err?.message ?? '', /ESME_RTHROTTLED/, 'the peer keeps a message the handler failed on'); ++ assert.ok(reported instanceof Error, 'a throwing handler should reach the session'); ++ assert.equal(reported.message, 'handler exploded'); + }); + +- // The guard for a throwing sms listener used to emit sessionError from inside its own catch. + test('survives a sessionError listener that throws as well', async t => { +- const smpp = await startServer(t); ++ const smpp = await startServer(t, { onSms: () => { throw new Error('handler exploded'); } }); + + smpp.on('session', session => { + session.on('sessionError', () => { throw new Error('the reporter exploded too'); }); +- session.on('sms', () => { throw new Error('listener exploded'); }); + }); + +- const { session } = await connect(t, smpp, { responseTimeout: 200 }); +- +- assert.ok(session); +- +- const sent = await session.sendSms({ ++ const esme = await bound(t, smpp, { responseTimeout: 200 }); ++ const sent = await esme.sendSms({ + from: '46701113311', +- message: 'blows up both listeners', ++ message: 'blows up both', + to: '46709771337', + }); + + assert.ok(sent.err instanceof Error); + }); + +- test('normalises whatever a rejecting async sms listener threw into a session error', async t => { +- const smpp = await startServer(t); ++ test('normalises whatever a rejecting async handler threw into a session error', async t => { + const reason: unknown = null; +- const failed = once(resolve => { +- smpp.on('session', session => { +- session.on('sessionError', resolve); +- session.on('sms', async sms => { +- await sms.sendResp(); ++ const smpp = await startServer(t, { ++ onSms: async sms => { ++ await sms.sendResp(); + +- throw reason; +- }); +- }); ++ throw reason; ++ }, + }); +- const { session } = await connect(t, smpp, { responseTimeout: 200 }); +- +- assert.ok(session); +- +- const sent = await session.sendSms({ ++ const failed = once(resolve => { ++ smpp.on('session', session => { session.on('sessionError', resolve); }); ++ }); ++ const esme = await bound(t, smpp, { responseTimeout: 200 }); ++ const sent = await esme.sendSms({ + from: '46701113311', + message: 'rejects after answering', + to: '46709771337', + }); + const reported = await raceWithin(500, failed); + +- assert.equal(sent.err, undefined); +- assert.ok(reported instanceof Error, 'a rejecting sms listener should reach the session'); ++ assert.equal(sent.err, undefined, 'the answer it gave before failing stands'); ++ assert.ok(reported instanceof Error, 'a rejecting handler should reach the session'); + assert.equal(reported.message, 'null'); + }); + + test('survives a sessionError listener that rejects as well', async t => { +- const smpp = await startServer(t); ++ const smpp = await startServer(t, { onSms: () => Promise.reject(new Error('handler rejected')) }); + + smpp.on('session', session => { + session.on('sessionError', () => Promise.reject(new Error('the reporter rejected too'))); +- session.on('sms', () => Promise.reject(new Error('listener rejected'))); + }); + +- const { session } = await connect(t, smpp, { responseTimeout: 200 }); +- +- assert.ok(session); +- +- const sent = await session.sendSms({ ++ const esme = await bound(t, smpp, { responseTimeout: 200 }); ++ const sent = await esme.sendSms({ + from: '46701113311', +- message: 'rejects in both listeners', ++ message: 'rejects in both', + to: '46709771337', + }); + +@@ -2089,9 +1966,9 @@ describe('application hooks that throw or reject', () => { + session.on('close', () => { throw new Error('close listener exploded'); }); + }); + +- const { session } = await connect(t, smpp); ++ const { client: esme } = await connect(t, smpp); + +- assert.ok(session); ++ assert.ok(esme); + await smpp.close(); + assert.equal(smpp.sessions.size, 0); + }); +@@ -2099,43 +1976,30 @@ describe('application hooks that throw or reject', () => { + test('sends on through an application logger that throws', async t => { + const thrower = (): void => { throw new Error('the logger exploded'); }; + const log: SmppLog = { debug: thrower, error: thrower, info: thrower, verbose: thrower, warn: thrower }; +- const smpp = await startServer(t, { log }); +- +- smpp.on('session', session => { +- session.on('sms', sms => { void sms.sendResp(); }); +- }); +- +- const { session } = await connect(t, smpp, { log }); +- +- assert.ok(session); +- +- const sent = await session.sendSms({ from: '46701113311', message: 'logged', to: '46709771337' }); ++ const smpp = await startServer(t, { log, onSms: () => undefined }); ++ const esme = await bound(t, smpp, { log }); ++ const sent = await esme.sendSms({ from: '46701113311', message: 'logged', to: '46709771337' }); + + assert.equal(sent.err, undefined); + }); + + test('refuses a send window that can never free a slot', async t => { + const smpp = await startServer(t); +- const { err, session } = await connect(t, smpp, { maxOutstanding: 0 }); ++ const { err, client: esme } = await connect(t, smpp, { maxOutstanding: 0 }); + + assert.ok(err instanceof Error); + assert.match(err.message, /maxOutstanding/); +- assert.equal(session, undefined); ++ assert.equal(esme, undefined); + + const negative = await connect(t, smpp, { shutdownTimeout: -1 }); + + assert.ok(negative.err instanceof Error); + assert.match(negative.err.message, /shutdownTimeout/); +- assert.equal(negative.session, undefined); ++ assert.equal(negative.client, undefined); + }); + + test('keeps the message id off a submit_sm_resp that refuses the message', async t => { +- const smpp = await startServer(t); +- +- smpp.on('session', bound => { +- bound.on('sms', sms => { void sms.sendResp({ status: 'ESME_RMSGQFUL' }); }); +- }); +- ++ const smpp = await startServer(t, { onSms: () => ({ status: 'ESME_RMSGQFUL' }) }); + const peer = rawPeer(t, smpp.port); + + peer.write(bindOf(0x34)); +@@ -2158,18 +2022,19 @@ describe('application hooks that throw or reject', () => { + assert.deepEqual(refused.params, {}); + }); + +- test('keeps the reconnect loop alive when connect throws', async t => { ++ test('keeps the reconnect loop alive when open throws', async t => { + let attempts = 0; + const loop = new ReconnectLoop({ +- connect: () => { ++ log: silentLog, ++ maxDelay: 10, ++ minDelay: 1, ++ onDown: () => undefined, ++ onUp: () => undefined, ++ open: () => { + attempts++; + + throw new Error('connect exploded'); + }, +- log: silentLog, +- maxDelay: 10, +- minDelay: 1, +- onConnected: () => Promise.resolve({}), + }); + + t.after(() => { loop.stop(); }); +@@ -2177,7 +2042,7 @@ describe('application hooks that throw or reject', () => { + + const retried = await waitFor(() => attempts >= 2); + +- assert.ok(retried, 'a throwing connect should be retried, not left for the process to die on'); ++ assert.ok(retried, 'a throwing open should be retried, not left for the process to die on'); + }); + + test('keeps backing off when every link dies as soon as it comes up', async t => { +@@ -2193,53 +2058,67 @@ describe('application hooks that throw or reject', () => { + verbose: noop, + warn: noop, + }; ++ const sessions: Session[] = []; + let up = 0; + const loop = new ReconnectLoop({ +- connect: () => Promise.resolve({ sock: new net.Socket() }), + log, + maxDelay: 80, + minDelay: 10, + now: () => clock.now, +- onConnected: () => { +- up++; ++ onDown: () => undefined, ++ onUp: () => { up++; }, ++ open: () => { ++ const session = new Session({ sock: new net.Socket() }); + +- return Promise.resolve({}); ++ sessions.push(session); ++ ++ return Promise.resolve({ session }); + }, + }); + +- t.after(() => { loop.stop(); }); ++ t.after(async () => { ++ loop.stop(); ++ ++ for (const session of sessions) { ++ await session.close({ signal: AbortSignal.abort() }); ++ } ++ }); + + for (let died = 0; died < 4; died++) { + loop.schedule(); + await waitFor(() => up === died + 1); ++ await loop.current()?.close(); + await delay(5); + } + + assert.deepEqual(delays, [10, 20, 40, 80]); + + // A link that outlasted the longest wait earned a fresh start. +- clock.now += 80; + loop.schedule(); +- await waitFor(() => delays.length === 5); ++ await waitFor(() => up === 5); ++ clock.now += 80; ++ await loop.current()?.close(); ++ await waitFor(() => delays.length === 6); + +- assert.deepEqual(delays, [10, 20, 40, 80, 10]); ++ assert.deepEqual(delays, [10, 20, 40, 80, 80, 10]); + }); + + test('starts only one reconnect attempt at a time', async t => { + let attempts = 0; + let finish: (() => void) | undefined; + const loop = new ReconnectLoop({ +- connect: () => { ++ log: silentLog, ++ maxDelay: 5, ++ minDelay: 1, ++ onDown: () => undefined, ++ onUp: () => undefined, ++ open: () => { + attempts++; + + return new Promise(resolve => { + finish = () => { resolve({ err: new Error('no socket') }); }; + }); + }, +- log: silentLog, +- maxDelay: 5, +- minDelay: 1, +- onConnected: () => Promise.resolve({}), + }); + + t.after(() => { +@@ -2250,48 +2129,44 @@ describe('application hooks that throw or reject', () => { + + assert.ok(await waitFor(() => attempts === 1)); + +- // A second drop landing while the first attempt is still inside connect(). ++ // A second drop landing while the first attempt is still inside open(). + loop.schedule(); + await delay(30); + + assert.equal(attempts, 1); + }); ++ + }); + + describe('the server\'s onRequest hook', () => { + const answeredId = '01a07501-b609-7d27-ab98-d3b29bd78e7e'; + +- function submitTo(session: Session, to: string, message = 'screened by the hook') { +- return session.send({ ++ function submitTo(esme: SmppClient, to: string, message = 'screened by the hook') { ++ return esme.send({ + cmdName: 'submit_sm', + params: { destination_addr: to, short_message: message, source_addr: '46701113311' }, + }); + } + + test('refuses an inbound submit_sm with the status the hook chose', async t => { ++ let messages = 0; + const smpp = await startServer(t, { +- onRequest: async (bound, pduObj) => { ++ onRequest: async (peer, pduObj) => { + if (!isCommand(pduObj, 'submit_sm')) return false; + +- await bound.sendReturn(pduObj, 'ESME_RINVDSTADR'); ++ await peer.sendReturn(pduObj, 'ESME_RINVDSTADR'); + + return true; + }, ++ onSms: () => { messages++; }, + }); +- let messages = 0; +- +- smpp.on('session', bound => bound.on('sms', () => { messages++; })); +- +- const { session } = await connect(t, smpp); +- +- assert.ok(session); +- +- const answered = await submitTo(session, '46700000000'); ++ const esme = await bound(t, smpp); ++ const answered = await submitTo(esme, '46700000000'); + + assert.equal(answered.err, undefined); + assert.ok(answered.pduObj); + assert.equal(answered.pduObj.cmdStatus, 'ESME_RINVDSTADR'); +- assert.equal(messages, 0, 'a request the hook answered never reaches the sms event'); ++ assert.equal(messages, 0, 'a request the hook answered never reaches the handler'); + }); + + test('answers a bind itself, so a hook that claims every request cannot intercept one', async t => { +@@ -2332,26 +2207,19 @@ describe('the server\'s onRequest hook', () => { + assert.deepEqual(seen, [], 'a bind, and everything a peer sends before one, is never the hook\'s'); + }); + +- test('passes a declined request to the sms event, and offers the keepalive and the unbind too', async t => { ++ test('passes a declined request to the handler, and offers the keepalive and the unbind too', async t => { + const seen: string[] = []; ++ const handed = handedOver(received => received.sendResp({ smsId: answeredId })); + const smpp = await startServer(t, { +- onRequest: (_bound, pduObj) => { seen.push(pduObj.cmdName); return false; }, ++ onRequest: (_peer, pduObj) => { seen.push(pduObj.cmdName); return false; }, ++ onSms: handed.onSms, + }); +- const incoming = once(resolve => { +- smpp.on('session', bound => bound.on('sms', async sms => { +- resolve(sms); +- await sms.sendResp({ smsId: answeredId }); +- })); +- }); +- const { session } = await connect(t, smpp); +- +- assert.ok(session); ++ const esme = await bound(t, smpp); ++ const answered = await submitTo(esme, '46709771337', 'declined by the hook'); ++ const sms = await handed.sms; + +- const answered = await submitTo(session, '46709771337', 'declined by the hook'); +- const sms = await incoming; +- +- await session.send({ cmdName: 'enquire_link' }); +- await session.unbind(); ++ await esme.send({ cmdName: 'enquire_link' }); ++ await esme.unbind(); + + assert.ok(answered.pduObj); + assert.equal(answered.pduObj.cmdStatus, 'ESME_ROK'); +@@ -2361,21 +2229,16 @@ describe('the server\'s onRequest hook', () => { + }); + + test('reports a hook that throws and answers nothing for it', async t => { ++ let messages = 0; + const smpp = await startServer(t, { + onRequest: () => { throw new Error('the onRequest hook exploded'); }, ++ onSms: () => { messages++; }, + }); + const failed = once(resolve => { +- smpp.on('session', bound => { bound.on('sessionError', resolve); }); ++ smpp.on('session', peer => { peer.on('sessionError', resolve); }); + }); +- let messages = 0; +- +- smpp.on('session', bound => bound.on('sms', () => { messages++; })); +- +- const { session } = await connect(t, smpp, { responseTimeout: 300 }); +- +- assert.ok(session); +- +- const answered = await submitTo(session, '46709771337'); ++ const esme = await bound(t, smpp, { responseTimeout: 300 }); ++ const answered = await submitTo(esme, '46709771337'); + const reported = await failed; + + assert.ok(answered.err instanceof Error); +@@ -2384,21 +2247,16 @@ describe('the server\'s onRequest hook', () => { + }); + + test('reports a hook that rejects and answers nothing for it', async t => { ++ let messages = 0; + const smpp = await startServer(t, { + onRequest: () => Promise.reject(new Error('the onRequest hook rejected')), ++ onSms: () => { messages++; }, + }); + const failed = once(resolve => { +- smpp.on('session', bound => { bound.on('sessionError', resolve); }); ++ smpp.on('session', peer => { peer.on('sessionError', resolve); }); + }); +- let messages = 0; +- +- smpp.on('session', bound => bound.on('sms', () => { messages++; })); +- +- const { session } = await connect(t, smpp, { responseTimeout: 300 }); +- +- assert.ok(session); +- +- const answered = await submitTo(session, '46709771337'); ++ const esme = await bound(t, smpp, { responseTimeout: 300 }); ++ const answered = await submitTo(esme, '46709771337'); + const reported = await failed; + + assert.ok(answered.err instanceof Error); +@@ -2453,13 +2311,13 @@ describe('the server\'s onRequest hook', () => { + }, + shutdownTimeout: 2000, + }); +- const bound = once(resolve => { smpp.on('session', resolve); }); +- const { session } = await connect(t, smpp); ++ const accepted = once(resolve => { smpp.on('session', resolve); }); ++ const { client: esme } = await connect(t, smpp); + +- assert.ok(session); ++ assert.ok(esme); + +- const answered = await submitTo(session, '46709771337'); +- const serverSide = await bound; ++ const answered = await submitTo(esme, '46709771337'); ++ const serverSide = await accepted; + const started = Date.now(); + const closed = await serverSide.close(); + const waited = Date.now() - started; +@@ -2474,17 +2332,17 @@ describe('the server\'s onRequest hook', () => { + describe('link timers', () => { + test('closes a client link the peer has stopped answering', async t => { + const peer = await smscPeer(t); +- const { err, session } = await client({ ++ const { err, client: esme } = await client({ + enquireLinkInterval: 50, + port: peer.port, + reconnect: false, + }); + + assert.equal(err, undefined); +- assert.ok(session); +- closeAfter(t, session); ++ assert.ok(esme); ++ closeAfter(t, esme); + +- const closed = once(resolve => { session.on('close', () => { resolve(true); }); }); ++ const closed = once(resolve => { esme.on('close', () => { resolve(true); }); }); + + assert.ok( + await raceWithin(1000, closed), +@@ -2494,16 +2352,16 @@ describe('link timers', () => { + + test('reconnects a link that timed out', async t => { + const peer = await smscPeer(t); +- const { session } = await client({ ++ const { client: esme } = await client({ + enquireLinkInterval: 40, + port: peer.port, + reconnect: { maxDelay: 20, minDelay: 10 }, + }); + +- assert.ok(session); +- closeAfter(t, session); ++ assert.ok(esme); ++ closeAfter(t, esme); + +- const back = once(resolve => { session.on('reconnected', () => { resolve(true); }); }); ++ const back = once(resolve => { esme.on('reconnected', () => { resolve(true); }); }); + + assert.ok(await raceWithin(2000, back), 'a link that timed out should be reconnected'); + }); +diff --git a/test/tls.test.ts b/test/tls.test.ts +index 1e311f4..b28cb32 100644 +--- a/test/tls.test.ts ++++ b/test/tls.test.ts +@@ -1,6 +1,7 @@ + import assert from 'node:assert/strict'; + import net from 'node:net'; + import test, { describe } from 'node:test'; ++import type { OnSms } from '../src/session-options.ts'; + import type { Sms } from '../src/sms.ts'; + import type { SmppServer } from '../src/server.ts'; + import type { TestContext } from 'node:test'; +@@ -104,8 +105,9 @@ function createCertificate(): { cert: string; key: string } { + + const certificate = createCertificate(); + +-async function startServer(t: TestContext): Promise { ++async function startServer(t: TestContext, onSms?: OnSms): Promise { + const { err, server: smpp } = await server({ ++ ...(onSms ? { onSms } : {}), + port: 0, + tls: { cert: certificate.cert, key: certificate.key }, + }); +@@ -123,20 +125,27 @@ function once(register: (resolve: (value: T) => void) => void): Promise { + + describe('tls', () => { + test('binds over a verified handshake and delivers an SMS', async t => { +- const smpp = await startServer(t); +- const incoming = once(resolve => { +- smpp.on('session', session => session.on('sms', resolve)); ++ let onIncoming: ((sms: Sms) => void) | undefined; ++ const incoming = once(resolve => { onIncoming = resolve; }); ++ const smpp = await startServer(t, sms => { ++ onIncoming?.(sms); ++ ++ return { smsId: 'tls-id' }; + }); +- const { err, session } = await client({ ++ const { err, client: esme } = await client({ + host, + port: smpp.port, + tls: { ca: certificate.cert }, + }); + + assert.equal(err, undefined); ++ assert.ok(esme); ++ closeAfter(t, esme); ++ ++ const session = esme.session; ++ + assert.ok(session); + assert.equal(session.boundAs, 'transceiver'); +- closeAfter(t, session); + + const sock = session.sock; + +@@ -145,12 +154,8 @@ describe('tls', () => { + assert.equal(sock.getPeerCertificate().subject.CN, host); + + const [sms, sent] = await Promise.all([ +- incoming.then(async received => { +- await received.sendResp({ smsId: 'tls-id' }); +- +- return received; +- }), +- session.sendSms({ from: 'MyBrand', message: 'hello over tls', to: '46709771337' }), ++ incoming, ++ esme.sendSms({ from: 'MyBrand', message: 'hello over tls', to: '46709771337' }), + ]); + + assert.equal(sms.message, 'hello over tls'); +@@ -158,21 +163,21 @@ describe('tls', () => { + assert.equal(sent.err, undefined); + assert.deepEqual(sent.smsIds, ['tls-id']); + +- assert.deepEqual(await session.unbind(), {}); ++ assert.deepEqual(await esme.unbind(), {}); + }); + + test('returns an error rather than throwing when the certificate is not trusted', async t => { + const smpp = await startServer(t); +- const { err, session } = await client({ host, port: smpp.port, tls: {} }); ++ const { err, client: esme } = await client({ host, port: smpp.port, tls: {} }); + + assert.ok(err instanceof Error); + assert.match(err.message, /self.signed certificate/); +- assert.equal(session, undefined); ++ assert.equal(esme, undefined); + }); + + test('returns an error when the certificate does not cover the host', async t => { + const smpp = await startServer(t); +- const { err, session } = await client({ ++ const { err, client: esme } = await client({ + host: '127.0.0.1', + port: smpp.port, + tls: { ca: certificate.cert }, +@@ -180,7 +185,7 @@ describe('tls', () => { + + assert.ok(err instanceof Error); + assert.match(err.message, /altnames/); +- assert.equal(session, undefined); ++ assert.equal(esme, undefined); + }); + + test('refuses to listen over tls without a certificate', async () => { +diff --git a/todo.md b/todo.md +index 3e5b9c7..0997258 100644 +--- a/todo.md ++++ b/todo.md +@@ -21,16 +21,17 @@ public surface is documented in [README.md](README.md); this is the short form. + ```ts + import { client, server } from '@larvit/smpp'; + +-const { err, session } = await client({ host, password, port, username }); +-const { err: sendErr, pduObjs, smsIds } = await session.sendSms({ dlr, from, message, to }); +-await session.unbind(); ++const { err, client: esme } = await client({ host, onSms, password, port, username }); ++const { err: sendErr, pduObjs, smsIds } = await esme.sendSms({ dlr, from, message, to }); ++await esme.unbind(); + +-const { err: serverErr, server: smpp } = await server({ authenticate, port }); +-smpp.on('session', session => { +- session.on('sms', async sms => { ++const { err: serverErr, server: smpp } = await server({ ++ authenticate, ++ onSms: async sms => { + await sms.sendResp(); + if (sms.dlr) await sms.sendDlr('DELIVERED'); +- }); ++ }, ++ port, + }); + await smpp.close(); + ``` diff --git a/docs/comprehension-rewrite/lessons.md b/docs/comprehension-rewrite/lessons.md new file mode 100644 index 0000000..cee2d2d --- /dev/null +++ b/docs/comprehension-rewrite/lessons.md @@ -0,0 +1,59 @@ +# Lessons from three redesign rounds + +A four-seat comprehension panel reads the whole project: a junior, a mid, a maintainability senior and an inherited-system architect. It scores on an absolute 1–10 scale, where 7 = "Predictable: the layout answers where things live; the hard parts are hard because the problem is hard, few, localized and marked". There are four dimensions: Navigation, Locality, Shape and Self-sufficiency. The overall may not exceed the lowest dimension plus one. The target is a mean overall at least one full point above main. + +| | Overall per seat | Mean | Locality | +| --- | --- | --- | --- | +| Main today | 6, 6, 7, 6 | 6.25 | 5, 5, 6, 6 | +| A: internals only; a `Link` object per socket | 6, 6, 6, 6 | 6.0 | 5, 5, 6, 6 | +| B: internals only; the lifecycle as one state machine (reducer returns effects, `Session` runs them) | 6, 6, 6, 6 | 6.0 | 6, 6, 5, 6 | +| C: contract change; `onSms` handler option, every message answered `ESME_ROK` on arrival, `sendResp()` removed, `src/` grouped into session/, messages/, wire/, defs/ | 6, 6, 6, 6 | 6.0 | 6, 6, 6, 6 | +| D: contract change; `onSms` handler, message held while its promise runs, `sendResp()` kept, no handler means refuse with the retry status | 5, 6, 7, 6 | 6.0 | 5, 6, 6, 6 | + +Their full diffs are `drafts/draft-a.patch` to `drafts/draft-d.patch`; each carries the draft's own DESIGN.md. + +What the panels taught: +1. **Restructuring internals under the old contract does not move the scores (A, B).** The held-message timing contract capped every seat: six exits, a `setImmediate` turn, listener counts, `captureRejections` routed through a `WeakMap`. +2. **Changing the receiving contract to an `onSms` handler removed that cap (C, D).** In C no reader named the held-message flow; D's junior still did, because "answered" lived in three places (a closure flag, a store field, and `answeredOnArrival`). +3. **The new ceiling is the session lifecycle.** Seven of eight round-two seats named the same unit they would least want to modify: + - `LinkLife`: a 4-value phase plus a separate `stopped` flag, whose initial `'up'` is an exception to its own rule. + - Seven predicates over it (`isUp`, `isAttached`, `isOver`, `isStopped`, `retrying`, `awaitsNextLink`, `refusal`), read by `Session`, `OutgoingRequests` and `IncomingRequests`. + - `Session.linkLost`/`end`/`dropSocket`/`comeBackUp`, whose correctness hangs on call order. + - Listeners of `disconnected`/`close` re-entering `close()` synchronously. + - `ReconnectLoop`'s own stopped flag duplicating `LinkLife`'s. + - client.ts's `bindOn` relying on `close()` reaching `stop()` before its first await, stated in another file. + + B tried a single state machine, but under the old contract, where the held-message cap hid any gain. +4. **Also still cited:** + - `IncomingRequests`/`HeldMessages` call back into `Session` (`emit`, `sendReturn`, `close`, `listenerCount`). + - `OutgoingRequests`' several entry points, or lanes, and its retry loop, which depends on link state at each await. + - `ExpiringGroups` leaves enforcement to its three owners. + - The GSM 03.38 codec is still named `ascii` somewhere. + - `DlrMerger.close` really means "spend". + - There is no glossary for the SMPP terms (ESME, SMSC/MC, esm_class, data_coding, UDH, sar_*, TLV). + - SMPP section citations with no summary. + - Defaults are spread over several files. +5. **Goal checks the drafts raised:** + - C answers `ESME_ROK` before the application has taken the message, so a crash loses it. That is a goal 2 risk: "work the peer has no reason to send again is not dropped". + - D's "no handler, so refuse every inbound message with the retry status" is a judgement call. If you keep something like it, record it in docs/decisions.md with the goal it rests on. + +## Round three + +| | Overall per seat | Mean | Locality | +| --- | --- | --- | --- | +| E: an `onSms` handler whose message is answered when the handler returns, plus the lifecycle as one state machine (`connected, bound, closing, down, ended`) | 5, 7, 6, 6 | 6.0 | 5, 6, 6, 6 | +| F: a Session is one socket's life, bound once and ended once; reconnect is an `SmppClient` composed above it; `onSms` answered on return | 6, 6, 7, 6 | 6.25 | 5, 5, 6, 6 | + +Diffs: `drafts/draft-e.patch` and `drafts/draft-f.patch`. + +What round three taught: +- **The hardest unit moves every round.** + - Round 1: the held-message flow. + - Round 2: the handler contract removed it, and the lifecycle took its place. + - Round 3: F's one-socket session removed the lifecycle, and readers now name other units: + - `ExpiringGroups` with `Reassembler.trim`, named by 4 of 8 seats: `set()` or `weigh()` may evict the caller's own entry, reads mutate and fire callbacks, and three owners depend on drop order; + - the invariant "every inbound PDU gets exactly one answer", enforced jointly by `IncomingRequests` and `sms.ts`. + - E's state machine was still hard, because each transition's meaning is split across five callbacks in another file. +- **Juniors score Self-sufficiency 5** even with a README glossary. The cost is SMPP knowledge: spec section numbers with no summary, and the `data_coding` bit masks. That caps a junior's overall at 6. +- **A fix sometimes adds a smaller hard spot of its own**, as D's "answered" in three places and E's callbacks show. +- **The scale is coarse**: four integer seats, so one seat moving one point shifts the mean by 0.25. diff --git a/docs/comprehension-rewrite/panels/round-1-drafts-a-b.md b/docs/comprehension-rewrite/panels/round-1-drafts-a-b.md new file mode 100644 index 0000000..bdbd234 --- /dev/null +++ b/docs/comprehension-rewrite/panels/round-1-drafts-a-b.md @@ -0,0 +1,605 @@ +# Round 1: drafts A and B + +## Draft A, junior seat + +1. **Hardest places, ranked** + + 1. `src/outgoing-requests.ts:75-113` (`OutgoingRequests.request` / `requestDuringDrain` / `carrier`) together with `src/session.ts:336-371` (`linkLost`, `end`, `nextLinkExpected`). Whether a send is refused, waits or retries depends on `life`, `link.canCarry()` and `reconnectLoop`. Those live in `Session` and reach here only through the three `LinkView` closures, so reading one file means holding the other's state in my head. Line 82, `closing() && current().canCarry()`, beat me until I traced `carrier()` → `nextExpected()` → `life === 'open'`. Its comment ("refused as closed further on") points at the answer without giving it. `linkLost` reads `nextLinkExpected()` before `close()`, and only the comment explains why. That comment helped; the rest stayed half-opaque. + 2. `src/held-messages.ts:40-170` (`HeldMessage`, `HeldMessages.offer`), with `src/sms.ts:77-162` and `src/session.ts:104`. A message has six exits across three files: a `working` listener counter, `answered()` deferring by `setImmediate`, a `WeakMap` from `Sms` to hold, and `emit()` returning false meaning release. `captureRejectionSymbol` calls `this.link.held.rejected(...)`, which is always the current link, so the message is only found if the link has not been replaced since the emit. The numbered exit list in the doc comment is the only reason I followed this. + 3. `src/pdu.ts:84-136` (`resolveShortMessage`, `resolveBody`). The `CodingSource` idea is hard: `data_coding` is rewritten from whichever body "owns" it, an empty buffer counts as `message_payload`, and a string gets encoded while a Buffer does not. That is four branches at once. The read side has a hidden coupling too: at `pdu.ts:249` `readParams` passes the already-read `sm_length` to every wire type's `read`. Only the comment at `defs/commands.ts:19-23` hints at it. Partly resolved. + 4. `src/defs/encodings.ts:147-190` (`messageClassOf`, `messageClassEncoding`, `encodingByDataCoding`). Bit masks over GSM 03.38 coding groups, and I have no domain background for them. `ASCII` means GSM 03.38 7-bit, a name that lies; only the README encoding table fixed that for me. The `//` comment at 159-160 sits above the JSDoc of the function it describes, so it reads as floating. Stayed opaque at bit level. + 5. `src/dlr.ts:157-239` (`messageType`, `receiptStatus`, `dlrFromPdu`). There are four message types, TLV-over-body precedence for both id and state, and an `unmarked` case that needs both an id and a status to count as a receipt. Comments cite spec sections I can't check. It reads correctly, but only after two passes. + 6. `src/reassembly.ts:187-207` (`Reassembler.trim`) with `src/expiring-groups.ts:18,70-88`. `weigh()` may evict the very group being added, and `answered = parts.size - 1` subtracts the refused segment. The contract "enforces neither max nor timeout itself; only weigh() evicts" splits enforcement between owner and store. Resolved by the comments, but costly. + 7. `src/dlr-merger.ts:150-172` (`open`, `close`). `close()` does not close a group: it moves the base into a second `ExpiringGroups` called `spent`. The misleading name cost me a reread. The class doc resolved it. + 8. `src/drain.ts:20-56` (`drain`, `leftOf`, `messagesBudget`). The doc says "one budget", but messages get their own budget with a fallback while requests get what is left. 0 means "forever", so `leftOf` clamps to 1. Small but inverted. + +2. **Least want to modify:** `HeldMessages` / `HeldMessage` (`src/held-messages.ts`). Its lifetime is decided by timing (`setImmediate` so that `sendDlr` still goes out past a drain), by listener counts, by identity checks against reused sequence numbers, and by callers in `session.ts` and `sms.ts`. A change to any exit risks a drain that hangs or one that ends early, and nothing local would show it. + +3. **Expected hard, found easy:** `PduFramer`, `PendingRequests`, `SendWindow`, `ReconnectLoop`, and the TLV table's type-level keying (`defs/tlvs.ts`). `defs/types.ts` is 684 lines but repetitive and uniform. `client.ts` and `server.ts` are shallow and read top-down. + +4. **Prose debt:** + - Needed: + - The README encoding table, to learn that `ASCII` means GSM 7-bit. + - The AGENTS "GSM 7-bit is sent unpacked" section, to see why `segmentUnits` holds 153 against 134. + - The README "Server in depth" and "Shutdown" sections, to understand `answeredOnArrival` and what the drain waits for. + + Finding them meant scanning a 773-line README; AGENTS has no anchors from code to sections. + - The decisions index in AGENTS gives titles only. The reasoning is in `docs/decisions.md`, which I was not allowed to read, so rules like "a drain ignores `shutdownTimeout: 0`" had to be recovered from code comments. + - Told me nothing the code did not: + - The 0.4.0 defect table, which says nothing about the current code. + - Most of the AGENTS architecture list, which restates filenames. + - Most decision-index lines, which repeat what an adjacent code comment already says. + - One-liners such as "Sends a request and resolves with the peer's response" on `send()`. + + I opened no tests. + +5. **Scores.** The problem is intrinsically hard (a protocol I don't know, plus async link lifecycles); that gets no bonus below. + - **Navigation 7 (Predictable):** file names match behaviours one-to-one, but "what happens to a send during shutdown" lives across `session.ts`, `outgoing-requests.ts`, `link.ts` and `drain.ts`, with no single entry point. + - **Locality 5 (Honest middle):** `Session` injects its state as closures (`LinkView`, `Link.on`) and passes itself back into `HeldMessages` and `IncomingRequests`, and correctness hangs on ordering that is only named in comments (`linkLost` before `close`, `setImmediate` in `answered`). + - **Shape 6 (between 5 and 7):** fan-out stays bounded per level, but several names lie: `ASCII` for GSM, `DlrMerger.close` for "mark spent", `string` for a length-prefixed Octet String next to `cstring`, and `answered()` meaning "release a turn later". + - **Self-sufficiency 6 (between 5 and 7):** dense, spec-citing comments carry most units, but the domain vocabulary (GSM alphabet naming, segment budgets, what `answeredOnArrival` means) needs the README open beside the code. + - **Overall 6:** capped at locality plus one by the cross-file lifecycle state. + +SCORES nav=7 loc=5 shape=6 self=6 overall=6 + +## Draft A, mid seat + +1. **Hardest places, hardest first** + + 1. `src/held-messages.ts:252-435`, `HeldMessage` / `HeldMessages` (and `session.ts:95-107`, the `captureRejectionSymbol` override). There are six exits, each in a different method. I had to trace this chain across files: a listener rejects, Node's `captureRejections` calls the session, the session calls `this.link.held.rejected(rest[0])`, a WeakMap is searched by object identity, `listenerGaveUp()` counts down from a `listenerCount('sms')` taken when the message was offered, `answered()` waits a turn in `setImmediate`, `release()` compares the array by identity, `settle()` runs, and finally `IdleWaiters` wakes the drain in `drain.ts`. The comment listing the six exits made it readable. What stayed unclear: why the rejection goes to the *current* link's store, which may not be the link the message arrived on after a reconnect. I also had to work out that `send()` choosing `sendPastDrain` exists only because `OutgoingRequests.request` refuses sends while closing. + 2. `src/outgoing-requests.ts:510-613`, `request` / `requestDuringDrain` / `bindOnCurrentLink` / `requestOnCurrentLink` / `carrier`. That is four ways onto the wire, and each skips a different check. Line 517 (`closing() && current().canCarry()`) refuses only when a link is up. The comment "refused as closed further on" meant tracing `carrier()` to see that `nextExpected()` is false while closing. `responseTimeout` is reused as the deadline for waiting on a link, `deadline === 0` means forever, and the retry loop depends on `retryOnNextLink` from `Link.send`. The `LinkView` closures read `Session` private state from a distance. The comments resolved most of it after two reads. + 3. `src/session.ts:208-366`, `unbind` / `drain` / `comeBackUp` / `linkLost` / `end`. The ordering does the work. `unbind` drains, then goes around the closing refusal via `requestOnCurrentLink`. `closedOnUnbind` decides which error wins. `comeBackUp` sets `this.link` before the bind succeeds, and `linkLost` must read `nextLinkExpected()` before `close()` because a listener may re-enter. The inline comments ("Read before close()…", "close() can land while…") resolved it, but I had to hold five states at once. + 4. `src/reassembly.ts:187-207`, `Reassembler.trim`, with `src/expiring-groups.ts:245-315`, `ExpiringGroups.weigh`. `ExpiringGroups` applies its three limits differently: the owner checks `full`, the owner calls `takeExpired`, and only `weigh` evicts. Map insertion order stands in for age, and `set()` re-inserts, so a replaced entry becomes the newest. `trim` can evict its own group, and it then counts `size - 1` as lost because the refused segment "stays with the peer". The comments state each rule, but checking the arithmetic took three rereads. + 5. `src/pdu.ts:84-136`, `resolveShortMessage` / `resolveBody`, plus `pdu.ts:249`. `CodingSource` decides whether `short_message` or `message_payload` is allowed to set `data_coding`, and an empty buffer flips the answer. `readParams` passes `sm_length` as the length argument to *every* field read, and only `buffer.read` uses it. That is a hidden coupling that neither line mentions. With no SMPP background this stayed half-opaque. + 6. `src/defs/encodings.ts:1,105-190`, `EncodingName` and `messageClassEncoding` / `encodingByDataCoding`. The name `'ASCII'` means GSM 03.38, and nothing says so until `dataCodingByEncoding`'s comment. It also misleads in `message.ts:343` and `message.ts:402` (`resolved === 'ASCII'` means septet packing). The bit masks (`0x80`, `0xF0`, bits 3-2) were an algorithm I had no context for. Two comments sit stacked in reverse order at lines 159-161. The charter's "GSM 7-bit is sent unpacked" section explained the 153. + 7. `src/dlr.ts:357-439`, `messageType` / `receiptStatus` / `dlrFromPdu`. There are four message types, and `'unmarked'` becomes a receipt only if both an id and a state can be scraped. Otherwise `IncomingRequests.onDelivery` (`incoming-requests.ts:152`) quietly reroutes it to `onMessage`. The rule is local and commented, but it only makes sense with the spec's `esm_class` bits in mind. + 8. `src/client.ts:258-330`, `keepTrying` / `initialAttempts`. There is a second `ReconnectLoop` outside the session, a fresh `Session` per attempt, and a `lastErr` captured in closures. The code itself is clear; the cost was noticing that there are two loops. + +2. **Unit I would least want to modify:** `HeldMessages` / `HeldMessage` (`src/held-messages.ts`). Its correctness depends on timing (`setImmediate`), a listener count taken at `emit` time, identity lookups (the WeakMap, and array identity in the store), and callers in `session.ts`, `incoming-requests.ts`, `sms.ts` and `drain.ts` that each use a different exit. A change there fails as a hung or cut-short shutdown, which is hard to see in a test. + +3. **Expected hard, found easy:** the wire codec. `defs/types.ts` is long but uniform. `PduFramer`, `parseTlvs` / `writeTlvs`, `readOptionalParams` (its NULL-pad rule is commented), `ReconnectLoop`, `bind-direction.ts`, `sms-id.ts` and the `Result` convention were all quick. `udh.ts`'s `concatInfo` explains its walk over the header elements well enough for a newcomer to the domain. + +4. **Prose debt** + - **Needed:** + - The AGENTS.md architecture map was cheap and correct; it is how I found every file. The file list matches `src/`. + - The "GSM 7-bit is sent unpacked" section was necessary for `segmentUnits`. + - The README's Receive-SMS text was necessary to see why `sendResp()` on a multipart message writes nothing. + - Missing everywhere: a one-line glossary of ESME/SMSC, `esm_class`, `data_coding`, UDH and `sar_*`. The only one is the `LinkEnd` comment for ESME/SMSC. Comments cite spec sections ("SMPP 3.4 5.2.12") that I cannot open, so for someone new to the domain they are pointers, not definitions. + - The decisions index names rules ("the drain's wait on the application ignores `shutdownTimeout: 0`") that I then found stated in the code (`drain.ts` `messagesBudget`). The index cost scrolling and gave nothing the code did not. + - I opened no tests. + - **Told me nothing new:** + - The AGENTS "Defects found in 0.4.0" table: history, not needed to read this code. + - Most of the Conventions paragraph on test fixtures, for reading `src/`. + - Doc comments that restate the code: `Link.canCarry` ("Whether a request can go out on it right now"), `HeldMessages.isGone`, `Session.bindAllows` ("Consulted by the library's senders"), `IdleWaiters.settle`, `PduRefusedError`'s class comment. + +5. **Scores** + - **Navigation 7:** at the "predictable" anchor. The one-line-per-file map and concept-named files (`link.ts`, `drain.ts`, `reassembly.ts`) got me from symptom to file first try. It stops short of 9 because shutdown behaviour lives in five files (`session.ts`, `drain.ts`, `held-messages.ts`, `outgoing-requests.ts`, `idle-waiters.ts`). + - **Locality 5:** at the "honest middle" anchor. Most modules stand alone, but the held-message/drain path runs on hidden timing (`setImmediate`), a listener count taken early, rejection routing to whatever link is current, and `LinkView` closures reading `Session` private state. The order-dependent sequences in `Session.linkLost` and `unbind` add to it. + - **Shape 6:** between the middle and predictable anchors. Fan-out is bounded (`Session` → `Link` → `PendingRequests` / `HeldMessages` / `Reassembler`), but some names lie or clash: + - `'ASCII'` means GSM 03.38. + - In `sms.ts`, `answered` is both a mutable `{ smsId }` holder (line 81) and a handler function (line 69). + - `HeldMessage.held.held` chains through two different things both called `held`. + - `lostLink()` is a predicate named like an event. + - There are four request entry points on `OutgoingRequests`. + - **Self-sufficiency 6:** between the middle and predictable anchors. Comments are dense and carry the why at the call site (the six-exit list, "Read before close()"). But domain terms are never glossed and the spec section numbers point outside the repo, so the `data_coding` and `esm_class` bit logic in `encodings.ts` and `dlr.ts` cannot stand alone for a reader new to SMPP. + - **Overall 6:** capped at locality + 1. The hard parts are few and mostly marked, but the one I would fear most (held messages and the drain) spreads across files and relies on timing. + - **Intrinsic difficulty** (no bonus): moderate-high. The protocol has two segmentation spellings, receipts that share a command with messages, a direction-dependent `data_sm`, and graceful drain combined with reconnect. + +SCORES nav=7 loc=5 shape=6 self=6 overall=6 + +## Draft A, senior seat + +1. **Hardest places, ranked** + + 1. **`src/held-messages.ts:40` `HeldMessage`, and `HeldMessages.offer` at `:148`.** Following this one flow meant holding five files at once: + - `sms.ts:127` `sendResp` and `:210` `sendDlr`. + - The `setImmediate` in `answered()` at `:58`. + - `HeldMessage.send` at `:77`, which picks between `sendPastDrain` and `session.send` by asking `isHeld()`. + - `OutgoingRequests.requestDuringDrain`. + - `Session`'s `captureRejectionSymbol` at `session.ts:104`, which gets back to the hold through a `WeakMap` keyed on the `Sms`. + + A `sendDlr()` gets past a drain only while the release has not yet happened, and that is one event-loop turn. `working` is `listenerCount('sms')` taken at offer time, and the guarded `emit` returning false feeds exit 3. The six-exits comment and the README's Shutdown section settled it, but only after I had read both. + + 2. **`src/outgoing-requests.ts:75` `request`, with `:90` `requestDuringDrain`, `:115` `bindOnCurrentLink`, `:133` `requestOnCurrentLink` and `:160` `carrier`.** There are four ways onto a link, and each skips a different mix of four things: the drain refusal, the send window, the wait for a link and the retry. + - Line 94, `closing() && current().canCarry()`, only makes sense with the comment "refused as closed further on". + - `misuse()` is checked twice. + - Whether the bind and the unbind count toward the drain's `window.idle()` has to be worked out from the fact that they skip `attemptOn`. + + I followed it in the end, but did not come away sure of the edge cases. + + 3. **`src/session.ts:234` `answer`, with `incoming-requests.ts:85` and `sms.ts:127`.** `sendReturn` always writes to `this.link`, the current link. The rule that "an answer belongs to the link the message arrived on" is held by callers checking `link.isClosed()` or `lostLink()` before they call, in two separate places. `sendReturn` never enforces it. I had to hunt for this, and only the decision titles in AGENTS.md told me the rule exists. + + 4. **`src/pdu.ts:84` `resolveShortMessage` / `:113` `resolveBody`.** `CodingSource` decides whether `short_message` or `message_payload` sets `data_coding`. I had to hold these cases at once: + - Buffer or string or absent. + - Empty or non-empty. + - `data_coding` given or not. + - An empty `short_message` that still makes `message_payload` the source. + + The type comment at `:74` helps. It still took two reads. + + 5. **`src/reassembly.ts:111` `collect` / `:188` `trim`, on top of `expiring-groups.ts:18`.** `ExpiringGroups` enforces its limits unevenly: + - `set()` never evicts and `weigh()` does. + - `full` is only advisory. + - `onSweep` must itself call `takeExpired()`. + + `trim` counts `parts.size - 1` because the segment was added before weighing and may be the one evicted. The comments state each quirk, but I needed all of them at the same time. + + 6. **`src/session.ts:208` `unbind` and `:336` `linkLost`.** In `unbind`, the three booleans `wasOpen`, `closedOnUnbind` and `drained` decide which error wins. In `linkLost`, "read `nextLinkExpected` before `close()`" depends on `link.close()` calling `reassembler.clear()`, which emits `sessionError` synchronously to a listener that might call `close()`. That is state changed out of sight. The comment names the risk but not the path it takes. + + 7. **`src/dlr-merger.ts:150` `open` / `:165` `close`.** Here `close` means "mark as spent", not "tear down", and `spent` is a second `ExpiringGroups` with its own cap and eviction. The class comment explains the purpose, but the method name misleads. + + 8. **`src/client.ts:286` `keepTrying` / `:263` `initialAttempts`.** There are two different `ReconnectLoop` owners: the session's loop, and a separate one that runs only for the first connect. There is also a `lastErr` closure and a comment about `unref: false`. It was readable once I saw that `fromStart` builds a fresh `Session` for every attempt. + +2. **The unit I would least want to modify:** `OutgoingRequests` together with its callers `HeldMessage.send` and `Session.unbind`. Whether a send is refused, queued or bypassed depends on which of the four entry points was chosen, and those choices are made in three other files. A change to one bypass has no local test of whether the drain still counts it. + +3. **Expected hard, found easy:** + - The codec: `defs/types.ts` and the TLV read/write. It is mechanical and every read is range-checked. + - UDH walking in `udh.ts`. + - GSM encoding and `splitMessage`. + - Parsing receipt dates. + - `ReconnectLoop`'s backoff reset. + + These are local and commented at the right spots, with SMPP section references. + +4. **Prose debt.** + - **Needed:** the AGENTS.md architecture map (accurate, and my main way to navigate). The README "Session / Shutdown" and "Sends and the link" bullets, for the drain and held-message rules. The AGENTS decision-index titles, for why answers are tied to a link and why a close after our own `unbind` is clean. Cost was moderate: all of it in two files I had already read, but the "one turn later" rule for `sendDlr` is stated in full only in README step 1. + + A comment that is wrong: `SessionOptions.shutdownTimeout` says it bounds only "the requests already on the wire", but `drain.ts:25` also uses it for held messages. + + Defaults are scattered with no pointer between them: + - A separate `defaults` object in each of `client.ts`, `server.ts` and `session-options.ts`. + - `backoffDefaults` in `reconnect-loop.ts`. + - `defaultMaxOctets` in `reassembly.ts`, which duplicates `maxHeldOctets`. + + Finding where the default for a given option lives took a search. + - **Told me nothing about the current code:** + - The AGENTS 0.4.0 defects table: history, and it never helped me read `src/`. + - Most of AGENTS "Conventions", which is about test fixtures and teardown. + - One-liners that restate the code, such as `/** Sends a request and resolves with the peer's response. */` and `/** Answers a request the peer sent us. */`, plus the `ConcatInfo`/`udhLength` comments. + + I did not open any test. + +5. **Scores.** The problem is intrinsically hard: an SMPP session layer with reconnect, drain, windowing, reassembly and receipt merging. It gets no bonus here. + - **Navigation 7** (anchor 7): the AGENTS file map answers "where does this live" accurately for every file. It is held below 8 because a symptom like "`sendDlr` refused during shutdown" lands across four files, and "which default" across three objects all called `defaults`. + - **Locality 6** (between 5 and 7): the codec, defs, dlr and message modules are fully local. The session layer is not: `Session` passes itself into `IncomingRequests`, `HeldMessages` and `createSms`, the link-answer rule is enforced by callers, and the drain bypass depends on a `setImmediate` in another file. + - **Shape 6** (between 5 and 7): fan-out is bounded and most names are true. Some are not: + - `ASCII` means GSM 03.38. + - `HeldMessages.full()` sweeps, logs and flips state. + - `DlrMerger.close` means "mark as spent". + - `Session.link` is documented as "the latest socket". + + Four near-identical send entry points on `OutgoingRequests` also cost this score. + - **Self-sufficiency 7** (anchor 7): the why-comments sit where they are needed (the six exits, the read-before-close note, the SMPP section numbers). Only the drain and held-message semantics needed the README open beside the code. + - **Overall 6:** capped at 7 by locality, and held at 6 because the part that is hardest to change safely, the session layer, is also where the cross-file coupling sits. + +SCORES nav=7 loc=6 shape=6 self=7 overall=6 + +## Draft A, architect seat + +**Comprehension panel report: Architect, inherited.** Target: `/tmp/claude-1000/-home-lilleman-code-smpp-js/fe9b6791-4543-5342-9fc7-efa1e22d8fc7/scratchpad/draft-a` + +I read README.md, AGENTS.md in draft-a, and every non-test file under src/. I read `defs/` down to its exports and skimmed the rest of it for shape. I opened no test. + +## 1. Map from README and the file tree only (verbatim) + +``` +Top-level areas I expect (7): +A. Entry/wiring — index.ts (public surface), client.ts (connect+bind+reconnect policy), server.ts (listener, auth, sessions set). +B. Session life — session.ts (the EventEmitter, lifecycle, close/unbind), session-options.ts (options + defaults + validation), + bind-direction.ts (bind types, which end, what a bind allows), reconnect-loop.ts (backoff), link.ts (?? one socket? timers?), + drain.ts (graceful-shutdown wait). +C. Requests out — outgoing-requests.ts (send path), pending-requests.ts (seqNr correlation + timeout), + send-window.ts (maxOutstanding), unanswered-error.ts (the "may have been taken" error), idle-waiters.ts (?? something waits for zero). +D. Requests in / messages — incoming-requests.ts (dispatch of what the peer sends), sms.ts (the 'sms' handle, sendResp/sendDlr), + held-messages.ts (the 1000-unanswered bound from README "Unanswered messages"), reassembly.ts + expiring-groups.ts + (multipart store; expiring-groups probably shared), concat.ts + udh.ts (segment detection UDH vs sar_*), + message-body.ts (short_message vs message_payload), message.ts (encode/split), send-sms.ts (sendSms composition), + retained-pdu.ts (?? memory accounting per maxOctets). +E. Receipts — dlr.ts (parse), dlr-merger.ts (messageDlr), sms-id.ts (smsIdFormat notations, -). +F. Codec — pdu.ts, pdu-framer.ts, pdu-refusal.ts (PduRefusedError), defs/* (spec tables, wire types, encodings). +G. Plumbing — result.ts, log.ts, error-from.ts (?? error from unknown), uuid.ts. + +Unclear by name: link.ts, idle-waiters.ts, retained-pdu.ts, error-from.ts; overlap suspected between drain.ts / idle-waiters.ts / held-messages.ts. +Expected but not visible: no keepalive/timer file (README's enquire_link + idleTimeout) — guess it's in link.ts or session.ts; +no socket/transport file; no store (goal 9) — README says it has not shipped, so absence is honest; interop-tests/ and benchmarks/ are outside src/test. +``` + +**Where the map was wrong, and what each correction cost:** +- **link.ts (medium).** I expected a socket plus its timers. `Link` also owns the `PendingRequests`, the `Reassembler` and the `HeldMessages`. So held messages and reassembly belong to one socket, not to the session. That changes how you reason about the drain after a reconnect, and I had to rebuild part of the model. +- **held-messages.ts (medium).** I expected a counter. It holds six exit paths, a WeakMap for listener rejections and a back-reference to `Session`. It also sends through a bypass path (`sendPastDrain`). +- **sms.ts (low to medium).** I expected only the inbound handle. It also builds outbound delivery receipts (`receiptText`, `receiptTlvs`, `collectReceipt`), while `dlr.ts` parses them. Writing and reading receipts are split across two files. +- **Smaller surprises (low).** `reassembly.ts` exports `decodeSegments`, which `sms.ts` uses to build `sms.message`. `message.ts` also holds `smppTime` and `smppDate`. +- **drain, idle-waiters, retained-pdu, error-from (cheap).** Each turned out as guessed. `drain.ts` is budget arithmetic only. +- **Keepalive (right).** The timers are in `link.ts` (`resetTimers`). + +## 2. Fan-out level by level +- **L0, repo:** src/, test/, README, AGENTS. Trivial. +- **L1, src/: 35 files plus defs/, all flat. This is the worst level.** My map needed 7 areas, but the layout shows none of them, and the AGENTS.md architecture list is not grouped by area either. I had to hold about 36 names to sort them. +- **L2, defs/:** 7 files, all spec tables. Bounded. +- **L3, units:** + - `session.ts`: about 20 members, but grouped, with a stated invariant ("every event about the life is emitted from one of these four"). + - `link.ts`: about 12 members. + - `outgoing-requests.ts`: 4 public ways in (`request`, `requestDuringDrain`, `requestOnCurrentLink`, `bindOnCurrentLink`), the one level where the fan-out is too wide for the concept. + - `defs/types.ts`: 684 lines, but a table of wire types, so it is wide without being hard. + +## 3. Names +**Misleading:** +- **`idle`** means two things. `HeldMessages.idle()`, `SendWindow.idle()`, `OutgoingRequests.idle()` and `IdleWaiters` mean "wait until the count reaches zero". `idleTimeout` and "closing an idle peer" in link.ts mean the peer has gone silent. +- **`ExpiringGroups`** enforces neither its `max` nor its timeout (its own comment says owners must). `DlrMerger.spent` is an `ExpiringGroups`, a set dressed as groups. +- **`EncodingName 'ASCII'`** means GSM 03.38. It is public, legacy and documented, but it is still a false name. +- **`lostLink()`** on `SmsHandlers` is a predicate named like an event. +- **`message.ts`** also holds `smppTime` and `smppDate`. + +**One concept with two or more names:** +- **Answering a request has four spellings:** `sendReturn`, `pduReturn`, `Session.answer()` and `sendResp`. +- **Letting a send past the drain has two:** `sendPastDrain` and `requestDuringDrain`. +- **The socket has three:** link, `sock` and socket. +- **A delivery report has three:** receipt, dlr and report. +- **A segment has two:** part and segment. + +**One name over two concepts:** +- **"held"** covers messages the application has not answered (`Link.held`), requests waiting for a link ("holding a request until a link is back", "sending what was held for a link"), and `HeldMessages.held`, the inner ExpiringGroups. +- **`drain`** is both `Session.drain()` (private) and `drain()` in drain.ts. +- **`defaults`** is three different objects: session-options.ts, client.ts and server.ts, with `systemId` in two of them. +- **`Waiter`/`waiting`** is defined separately in outgoing-requests.ts and send-window.ts with different meanings. +- **"refuse/refusal"** covers codec refusal (`PduRefusedError`), segment refusal (`Refusal 'full'|'unplaceable'`) and option refusal in send-sms. + +## 4. What I would restructure, ranked +1. **Group src/ into about 5 directories:** codec/, link+session/, inbound messages/, outbound send/, receipts/, plus defs/. This is the only thing pushing L1 past its bound. AGENTS.md records "src/ stays flat" as a decision, and I would contest it: at 36 files, the map lives in AGENTS.md, not in the layout. +2. **Settle on one verb for answering a request.** +3. **Move receipt composition out of sms.ts** next to the parsing in dlr.ts. Merge `collectReceipt` (`sms.ts:188`) with `collectSent` (`send-sms.ts:274`); they are near-duplicates. +4. **Make `ExpiringGroups` enforce its own `max` and weight, or rename it to say the caps are advisory.** Today three owners each implement eviction differently: `Reassembler.open` plus `trim`, `DlrMerger.dropOldest` plus `spent`, and `HeldMessages.full()` with its own weight comparison. +5. **Rename the "wait until zero" methods** (`idle()` → `drained()`), and give "held" one meaning. +6. **Move `smppTime` and `smppDate` into their own module, and keep one `defaults`.** + +**What the structure gets right:** +- `session.ts` is a readable orchestrator with a single exit for a link (`linkLost`) and a single end (`end`). +- `Link.close()` runs once and takes down everything tied to that socket. +- The Result discipline is uniform. +- The codec is pure and synchronous. +- Small modules with honest names: `pdu-framer`, `send-window`, `pending-requests`, `reconnect-loop`, `unanswered-error`. +- Log messages are static and prefixed with the unit (`'heldMessages - …'`, `'drain - …'`), so a log line leads straight to its unit. This is the strongest navigation aid in the code. +- Comments give the WHY, often with a spec section. + +## 5. The 3am question +Symptom: during a graceful shutdown the session hangs until the shutdown timeout, even though the application called `sms.sendResp()` on every message. + +**Cold time to the right unit: about 5 minutes.** +1. `Session.close` leads to `Session.drain` (`session.ts:251`). +2. That leads to `drain()` (`drain.ts:32`). Its err text and warn logs already say which half stalled: "messages unanswered" or "requests unfinished". +3. **Messages half:** `HeldMessages.idle` and `HeldMessages.release` (`held-messages.ts:185-222`), reached from `HeldMessage.answered()` (`held-messages.ts:57`). + - The gate is `sms.ts:159`, `if (!failure) handlers.answered()`. If any `sendReturn` for the message fails, the message is never released. Two ways that happens: an `smsId` the latin1 codec refuses, or a failed write. It then stays held until the drain budget runs out (and the 5-minute sweep drops it after that). The application saw `err` from `sendResp()` and ignored it. + - Release also works by object identity (`held.get(key) !== pduObjs`), so check that too. +4. **Requests half:** `sendDlr` goes past the drain through `requestDuringDrain` (`outgoing-requests.ts:90`). It holds a send-window slot until the peer answers the `deliver_sm`, so a peer that is itself shutting down leaves it until the deadline. That is the other likely cause, and nothing in the symptom rules it out. + +**Where it rots first:** the triangle of `HeldMessage`, `HeldMessages`, `Sms` and `Session`. +- `Link` is handed a `session` through its options. +- `HeldMessages` emits `'sms'` on `Session`. +- A listener rejection comes back through `Session[captureRejectionSymbol]` into `this.link.held.rejected(rest[0])` (`session.ts:104`). That is the current link, not necessarily the one the message arrived on. +- Release depends on ordering: `setImmediate` in `answered()` exists so that a `sendDlr()` called straight after `sendResp()` still gets past the drain. +- The next fix here will add a seventh exit. + +**Where the next two features would land:** +- **Goal 9, the store.** It lands on `ExpiringGroups`, the store its three owners share. That class is synchronous and in-memory, and each owner enforces part of its contract, so moving to an async store interface touches `DlrMerger`, `Reassembler` and `HeldMessages` at once. Expensive. +- **Goal 7, a per-PDU rate limit or a custom alphabet.** + - A rate limit lands cleanly at `OutgoingRequests.attemptOn` (`outgoing-requests.ts:124`). + - A custom alphabet runs into the closed `EncodingName` union. It spreads into `message.ts:14` (`segmentUnits`), `send-sms.ts:92` (`dataCodingFor`, with hard-coded 0x10/0x18), `defs/encodings.ts` and `bitCount`: 4 to 5 places. + +## 6. Hardest places, ranked +1. `src/held-messages.ts:40-223`, `HeldMessage` and `HeldMessages`: six exits, release by identity, WeakMap for rejections, `setImmediate` release, back-reference to the session, the drain bypass. +2. `src/session.ts:95-107`, `[captureRejectionSymbol]`: a rejected `'sms'` listener reaches the held messages through an `unknown` argument on the current link. Action at a distance. +3. `src/outgoing-requests.ts:75-137`: `request`, `requestDuringDrain`, `bindOnCurrentLink` and `requestOnCurrentLink` are four ways in with different bypass rules. The condition `closing() && current().canCarry()` (line 82) reads inverted until you notice the fall-through. +4. `src/expiring-groups.ts:19-147`, `ExpiringGroups`: the contract is half-enforced, and each owner fills in the rest differently. +5. `src/dlr-merger.ts:68-184`, `DlrMerger`: `groups` plus `spent`, and `close()` always marks an id spent. +6. `src/drain.ts:20-57` with `src/session.ts:251`: budget arithmetic where 0 means forever and the messages half falls back to `responseTimeout`. `session-options.ts:63` documents `shutdownTimeout` as covering only the requests on the wire, a partial truth next to the code that owns the behaviour. +7. `src/client.ts:228-330`, `bindOn`, `initialAttempts` and `keepTrying`: a second `ReconnectLoop` outside `Session`, with a fresh session per attempt. +8. `src/pdu.ts:84-136`, `resolveShortMessage` and `resolveBody`: which body field gets to set `data_coding`. + +**The unit I would least want to modify:** `HeldMessages` and `HeldMessage` in `src/held-messages.ts`. + +**Intrinsic difficulty (no bonus):** high. An SMPP session layer with reconnect, a drain split into two budgets, sequence-number correlation that belongs to one link, reassembly under caps, and asynchronous lifecycle races. Most of the hard spots are hard because of this. + +## 7. Scores +- **Navigation: 7.** At the "Predictable" anchor: honest file names plus unit-prefixed static log strings took the 3am symptom from `session.ts` to `drain.ts` to `held-messages.ts`/`sms.ts:159` with no detour. It stays below 9 because the flat 36-file src/ makes you consult AGENTS.md's list to find areas. +- **Locality: 6.** Between the anchors: `Link` and `Session` hold clean boundaries, but the held-message flow reaches back into `Session` through `Link` options, relies on `setImmediate` ordering, and gets rejections through `captureRejections` on the current link. And three owners each re-enforce `ExpiringGroups`' caps. +- **Shape: 6.** Between the anchors: the flat L1 of 36 files breaks the bound and has no grouping. "held", "idle", `defaults` and "drain" each name two things, and a response has four verbs. The files are small and single-purpose. +- **Self-sufficiency: 7.** At the "Predictable" anchor: invariants are stated at the code (the six-exits list, the `linkLost` comment, spec citations), and I needed no second document to follow a unit. It stays below 9 because of the partial `shutdownTimeout` doc at `session-options.ts:63` and the drain semantics, which only README fully states. +- **Overall: 6.** Held to the Locality and Shape 6s. It reads close to "Predictable": a cold senior would be productive within a week and would know to fear `held-messages.ts`. + +SCORES nav=7 loc=6 shape=6 self=7 overall=6 + +## Draft B, junior seat + +1. **Hardest places, ranked hardest first** + + 1. **`src/outgoing-requests.ts:70-168`, `OutgoingRequests.request` → `requestPastDrain` → `carry` → `attempt`.** A request has four ways in. One is a recursive retry (`carry` calls itself at :130) that fires only when `retryOnNextLink && link.awaitsNextLink()`. Each hop asks `LinkLife` a different question: `isStopping`, `canCarry`, `refusal`, `budget`, `awaitsNextLink`. I had to keep LinkLife's phase table open to follow it. The guard at :77, `isStopping() && canCarry()`, has the comment "With no link, the request is refused as closed further on". That describes the branch that is *not* taken here, and I read it three times. `requestPastDrain` is named for the caller that needs it (a receipt during a drain), not for what it does. The `UnansweredError` wrap at :167 was clear. The rest stayed half-opaque. + 2. **`src/link-life.ts:74-171`, `LinkLife.transition` / `lose` / `end`.** There are three pieces of state: `linkPhase`, `stopping` and `drops`. `stopping` is set in two places (:89, :167). `drops` increments both in `lose` and in `end`, and `bound` while stopping falls through to `lose()`. The effects returned are carried out elsewhere, in `session.ts:269` `run()`, so behaviour is split across two files in an order I had to trust. The type comments at :13-33 made it tractable. What really cost me was the initial phase at :60: `linkPhase = 'up'` on a socket that has not bound yet. That contradicts the `up` doc ("a bound socket carries requests") and the charter's "a bind is what makes it one". I never resolved why the first link starts `up`. + 3. **`src/held-messages.ts:40-181`, `HeldMessages`.** The numbered "six ways a hold ends" comment helps, but the ways live in three files. Way 2 enters from `session.ts:97`, where `captureRejectionSymbol` passes `rest[0]` as `unknown`. It is then looked up in a `WeakMap`, and `working` is seeded from `session.listenerCount('sms')` at :161. Way 1 arrives through a callback built in `sms.ts`. Way 3 depends on `emit` returning false, both for no listener and for a throw (the override in `session.ts:78`). I pieced this together; it did not stay opaque, but it was the most action at a distance in the codebase. + 4. **`src/pdu.ts:84-136`, `resolveShortMessage` / `resolveBody`.** `CodingSource` decides which of two fields may set `data_coding`. An empty buffer counts as `message_payload`, and `sm_length` is filled only sometimes. With no SMPP background I could not tell why an empty `short_message` hands authority to the TLV until I read `message-body.ts` and the README's "Where the body is". After that it made sense, but it took two files and a doc. + 5. **`src/client.ts:228-350`, `bindOn` / `initialAttempts` / `keepTrying` / `client`.** The `fromStart` path builds a second `ReconnectLoop` outside any session. Each attempt gets a fresh `Session`, and a `lastErr` closure is shared between two lambdas. The comment at :249, "close() must reach the loop's stop() before its first await", states an ordering invariant that lives in `session.ts` `drain()`/`apply('stopping')`. It is correct, but invisible from here. + 6. **`src/defs/encodings.ts:151-198`, `messageClassOf` / `messageClassEncoding` / `encodingByDataCoding`.** This is bit-masking over `data_coding` groups I had no context for. The `//` comment sits above the `/** */` block at :159-161, so it reads as belonging to the wrong function. The "ASCII" name meaning GSM 03.38 is a lie I only caught because the README's encoding table says so. It stayed partly opaque: I trust it, but I could not verify it. + 7. **`src/reassembly.ts:188-207`, `Reassembler.trim`.** `ExpiringGroups.weigh()` returns evicted groups, possibly including the current one. `answered = parts.size - 1` for the current group depends on the refused segment already being in `parts`. The comments at :200 and :125 resolved it after a reread. + 8. **`src/drain.ts:70-112`, `drain` / `answeringBudget`.** Two budgets with different zero-semantics, a fallback to `defaults.responseTimeout` imported from `session-options`, and a `setImmediate` turn. The comments explain each step. It was harder than it looked, but it resolved. + +2. **The unit I would least want to modify:** `OutgoingRequests.carry` together with `requestPastDrain` (`src/outgoing-requests.ts:85-133`). Its correctness depends on LinkLife's phase at the exact moment of each await: whether a failed write has already produced `lost` → `dropLink`, so that `awaitsNextLink()` is true. It also depends on the send-window slot being released in `finally` before the recursion. Nothing in the unit states those timing assumptions. A change would be guessed and then tested. + +3. **Expected to be hard, found easy.** + - The wire codec: `defs/types.ts` is long but completely regular, and every read/write is range-checked. + - `PduFramer`, `concatInfo`, `sms-id.ts`, `dlr.ts` receipt parsing (comments name the operators and spec sections). + - The `Result` pattern. + - `server.ts` `handleRequest`. + - `DlrMerger`, whose severity table comment says exactly why it exists. + - The session constructor, which reads as a wiring diagram. + +4. **Prose debt.** + - **Needed:** + - The SMPP vocabulary: ESME vs SMSC, `deliver_sm` vs `submit_sm` direction, `esm_class`, `data_coding`, TON/NPI, UDH vs `sar_*`. Only the README supplies it, scattered across "Receiving in depth", "Bind direction" and "Delivery receipts". There is no glossary, and finding each term cost several searches. + - The charter's "GSM 7-bit is sent unpacked" section, to understand `segmentUnits` in `message.ts:14`. Cheap, because the architecture map pointed there. + - The AGENTS decision index. Its lines, for example "One owner decides whether a link can carry a request", told me a rule exists but not its reasoning. I was not allowed to open `docs/decisions.md`, and the initial-`up` puzzle is exactly where I needed it. + - **Told me nothing:** + - The 0.4.0 defect table. It is history, and useless for reading the current code. + - The long test-fixture paragraph in Conventions. + - Duplicated doc comments: `deliver` "False means nothing was" appears in both `outgoing-requests.ts` and `pending-requests.ts`, and the `idle()` "Resolves 0 once…" wording is repeated four times. + - `/** Injected so expiry can be exercised without a wall clock. */` repeated on four option types. + - `get sock` "Replaced on reconnect" in `session.ts`, which restates `PduTransport`. + +5. **Scores.** The problem itself is hard (async link lifecycle, reconnect, protocol quirks); that gets no bonus below. + - **Navigation: 7.** At the "predictable" anchor: the AGENTS architecture map names every file by its question and the filenames match. It stops short of 9 because a behaviour like "a send during shutdown" spans `outgoing-requests.ts`, `link-life.ts`, `drain.ts` and `session.ts`, with no single landing file. + - **Locality: 6.** Between the middle and predictable anchors. The collaborators are cleanly split, but LinkLife's `stopping`/phase flags are read by OutgoingRequests at await boundaries, effects run in a different file from where they are decided, and HeldMessages is entered from the EventEmitter's `captureRejectionSymbol`. All of that is action at a distance. + - **Shape: 6.** Fan-out is bounded (Session has about 9 collaborators, each small), but some names lie: `ASCII` means GSM 03.38, `drain.ts` houses `IdleWaiters`, `requestPastDrain` is named for a caller, and the link starts in phase `up` before any bind. + - **Self-sufficiency: 5.** At the middle anchor. Comments are dense with spec citations and explain the WHY locally. Still, a reader without the domain needs README open for SMPP terms, and some rules exist only as index lines pointing at a decisions file. + - **Overall: 6.** Capped at 6 by self-sufficiency. The layout is good and the hard parts are really hard, but three of them (sections 1.1-1.3) need two or three files open at once. + +SCORES nav=7 loc=6 shape=6 self=5 overall=6 + +## Draft B, mid seat + +1. **Hardest places, hardest first** + + 1. **`src/link-life.ts:74` `LinkLife.transition()`, with `lose()` :148, `end()` :159, and `src/session.ts:263` `apply()` / :269 `run()`.** The table depends on two hidden variables besides the phase: the `stopping` flag and the `drops` counter. `lose()` sets the phase to `down` before it calls `end()`, and that is the only thing that stops `end()` counting the same drop twice. `'bound'` while stopping goes through `lose()` and ends up in `end()`. The effect order matters: `dropLink` clears held, incoming and outgoing before `emitClose`. The phase names mislead too. The doc at :14 says "`up`: a bound socket carries requests", but the phase starts as `up` (:60) on a socket nothing has bound yet, both on a server session and on a client before its bind. I only resolved that after reading `OutgoingRequests.requestPastDrain` (outgoing-requests.ts:85), where the bind path skips the link check. The table's JSDoc helps. The transitions themselves I had to trace by hand. + 2. **Shutdown, spread over `src/session.ts:237` `unbind()` / :300 `drain()`, `src/drain.ts:96` `drain()` / :70 `answeringBudget()`, `src/outgoing-requests.ts:70` `request()` / :85 `requestPastDrain()`, and `src/held-messages.ts` `sendReceipt`.** To know whether a send is refused I had to hold five things at once: `isStopping() && canCarry()`, the "refused as closed further on" path through `LinkLife.refusal()`, the receipt's route past the drain, the `setImmediate` turn between the two waits, and the fallback for `shutdownTimeout: 0`. `IdleWaiters` lives in `drain.ts` but serves `SendWindow` and `HeldMessages`, so I went to the wrong file for it once. The comments explain each step, but no single place explains the whole sequence. + 3. **`src/held-messages.ts:94` `offer()` / :117 `listenerRejected()` / :154 `keep()`.** Release path 3 depends on `Session.emit` being overridden (session.ts:78) to return `false` when a listener throws. Path 2 depends on `captureRejections` routing through session.ts:106 back into a `WeakMap` lookup by object identity. The `working` count is taken from `session.listenerCount('sms')` at keep time. That is action at a distance in both directions. The numbered "six ways" comment is what made it followable. + 4. **`src/client.ts:228` `bindOn()`, :263 `initialAttempts()`, :286 `keepTrying()`.** These are three layers of session creation for `fromStart`, with abort listeners added and removed at different points. The comment at :249 says `close()` has to reach `stop()` before its first await. That rule depends on `Session.drain()` calling `apply('stopping')` synchronously (session.ts:301). The comment resolved it, but it is an ordering dependency across files. + 5. **`src/pdu.ts:84` `resolveShortMessage()` / :113 `resolveBody()`.** The `CodingSource` idea (which of the two bodies gets to set `data_coding`) has about six branches: empty buffer, non-empty buffer, a string that encodes to empty, the command having no `short_message`, and a string `message_payload`. I had no domain background for why the payload may override `data_coding` only when `short_message` is empty. The type comment at :74 got me about half of it. + 6. **`src/reassembly.ts:188` `trim()` with `src/expiring-groups.ts:70` `weigh()`.** `weigh()` returns evicted groups, possibly including the current one, and `trim` then uses `parts.size - 1` for that one. `ExpiringGroups` enforces its limits unevenly: `max` never, `maxWeight` only in `weigh`. Its three users each handle that differently: `HeldMessages` checks the weight itself, and `DlrMerger` runs two instances (`groups` + `spent`, dlr-merger.ts:150/:165). The class comment says all this, but I needed a second pass. + 7. **`src/defs/encodings.ts:162` `messageClassEncoding()` / :182 `encodingByDataCoding()` / :151 `messageClassOf()`.** This is bit arithmetic over GSM 03.38 coding groups, which I have never seen. The comments cite the spec but I can't check them. It stayed partly opaque, but it is small and self-contained. + 8. **`src/outgoing-requests.ts:114` `carry()` / :141 `attempt()`.** A recursive retry that shares one link budget, where a retry happens only when `retryOnNextLink && awaitsNextLink()`. Inside `carry()`, `const held = await waitForLink()` reuses the word "held", which elsewhere means `HeldMessages`. Readable once you know the link model. + +2. **Least want to modify:** `LinkLife.transition()` together with `Session.run()`. Every lifecycle path goes through it (idle timeout, socket close, unreadable stream, unbind, close, rebind). The order of effects and the `stopping`/`drops` side state are invariants that nothing in the types enforces. A wrong effect order would show up as a leaked pending request or a double `close` event somewhere far away. + +3. **Expected hard, found easy:** the wire codec. `defs/types.ts`, TLV read/write and `PduFramer` are mechanical, range-checked and uniform. The same goes for `PendingRequests`, `SendWindow`, `ReconnectLoop`, the linear check chain in `send-sms.ts`, and `udh.ts`. Receipt parsing in `dlr.ts` was clearer than I expected for an unfamiliar domain. + +4. **Prose debt** + - **Needed, and cheap to find:** + - The charter's architecture list. It is accurate and maps one file to one question. + - The "GSM 7-bit is sent unpacked" section, needed for the 153/134 figures in `message.ts:129`. + - README "Bind direction" and "Receiving in depth", needed for the `data_sm` direction and for multipart being answered on arrival. + - README "Shutdown", needed to see why the drain waits on messages at all. + - **Needed, and costly:** nothing tells a newcomer what `esm_class`, `data_coding`, TON/NPI or `sar_*` are beyond scattered spec citations. I had to piece them together from the README and comments. + - **Stale prose:** `SessionOptions.shutdownTimeout` (session-options.ts:63) says "How long a drain waits for the requests already on the wire. 0 waits forever". That omits the wait on messages and the rule that 0 does not wait forever for them, which is exactly what `drain.ts` implements. + - **Told me nothing beyond the code:** + - The charter's defect table (0.4.0 history, no help reading today's code). + - The long test-fixture convention paragraph, for this read. + - The decision index titles, which I can't expand, and several of which repeat nearby code comments. + - `Injected so expiry can be exercised without a wall clock`, repeated in four option types. + - The duplicated `deliver()` doc on `OutgoingRequests` and `PendingRequests`. + - `/** Starts both timers over… */`-style restatements in `link-timers.ts`. + +5. **Scores** + - **Navigation: 8.** Above 7, below 9. The charter's per-file map and concept-named files took me straight to the right place almost every time. The detours were `IdleWaiters` living in `drain.ts`, and receipt-sending split between `sms.ts` and `OutgoingRequests.requestPastDrain`. + - **Locality: 6.** Between 5 and 7. Collaborators are injected and the lifecycle effects are an explicit list. But `HeldMessages` and `IncomingRequests` call back into `Session` (`emit` override semantics, `listenerCount`, `close`), and correctness depends on synchronous ordering (`apply('stopping')` before the first await, the `setImmediate` in `drain`). + - **Shape: 7.** The Session hub's fan-out is wide but flat (about 9 collaborators, each small). A few names lie: the `up` phase before any bind, the `ASCII` encoding meaning GSM 03.38, the word "held" reused in `carry()`, and two different `onConnected` signatures (a `Session` in `ReconnectOptions`, a `Socket` in `ReconnectLoopOptions`). + - **Self-sufficiency: 7.** Comments give the why and the spec section at nearly every surprising line, so most units stand alone. The domain vocabulary (the `esm_class` and `data_coding` bit layouts) and the whole shutdown story needed the README beside the code. + - **Overall: 6.** Held down by Locality. The hard part (link life plus the drain) is localized and marked, but it takes rereads. A mid-level reader takes about two days to feel safe in `session`, `link-life` and `held-messages`. + - **Intrinsic difficulty:** high. An asynchronous protocol session with reconnect, a send window, reassembly and a graceful drain, on a domain the reader doesn't know. That earns no bonus here. + +SCORES nav=8 loc=6 shape=7 self=7 overall=6 + +## Draft B, senior seat + +1. **Hardest places, ranked hardest first** + + 1. **The shutdown path**: `src/session.ts:237` (`unbind`), `:254` (`close`) and `:300` (`drain`); `src/drain.ts:96` (`drain`), `:70` (`answeringBudget`) and `:65` (`leftOf`); `src/outgoing-requests.ts:70` (`request`). + - To answer "what does a send do during shutdown?" I had to hold four files at once. `LinkLife.stopping` is set by `apply('stopping')`. `request()` refuses only when `isStopping() && canCarry()`. Otherwise it relies on `awaitsNextLink()` going false because `retrying()` checks `!stopping`, which I had to hunt for in `link-life.ts:132`. + - The two drain budgets differ in a way only the code shows. Messages fall back to `responseTimeout`; requests get `leftOf(deadline)`, clamped to at least 1 ms. There is also an ordering dependency on a `setImmediate` turn between the two waits (`drain.ts:108`). + - The comments are correct but terse. I resolved it after two reads, plus the README "Shutdown" section. + 2. **The held-message lifecycle**: `src/held-messages.ts:94` (`offer`), `:117` (`listenerRejected`) and `:154` (`keep`); `src/session.ts:106`; `src/sms.ts:91-93` and `:151-161`. + - A hold ends in six ways, and they are spread across three files. `Session`'s `captureRejectionSymbol` reaches into `held` by identity, through a `WeakMap`. `working` is a snapshot of `listenerCount('sms')` taken when the message is kept. + - The numbered "1–6" comments are what resolved it. Without them this was action at a distance. + 3. **The link state machine**: `src/link-life.ts:74` (`transition`), `:148` (`lose`) and `:159` (`end`). + - `lose()` sets `down` and then calls `end()`, which rereads `attached()` (now false). `drops` is incremented in two places. + - The initial `linkPhase = 'up'` (`:60`) contradicts the type doc at `:13-17`, which says `up` means "a bound socket carries requests". A fresh client or server socket is `up` before any bind. + - Session's `bound()` (records the bind) and LinkLife's `'bound'` event (a rebind was answered) share a name and mean different things. This stayed partly opaque until I traced `comeBackUp` (`session.ts:359`). + 4. **Reassembly under the octet cap**: `src/reassembly.ts:111` (`collect`) and `:188` (`trim`), with `src/expiring-groups.ts:70` (`weigh`). + - The segment is inserted before `trim`. `weigh` can evict the current group itself, and `answered = parts.size - 1` then excludes the refused segment from the loss. + - `ExpiringGroups` enforces max, timeout and weight differently: `full` is only a flag, `weigh` evicts, `set` never does. Its own doc comments make that explicit, which resolved it. + 5. **Encoding a body under `data_coding`**: `src/pdu.ts:84` (`resolveShortMessage`) and `:113` (`resolveBody`). + - `CodingSource` decides whether `short_message` or `message_payload` may overwrite `data_coding`. An empty encoded buffer flips the source to `message_payload`, and a Buffer `short_message` of length 0 does the same. + - I needed the decision titles in the charter to see why. The branches are stateful rather than hard, but I reread them three times. + 6. **The client's first connect**: `src/client.ts:333` (`client`), `:286` (`keepTrying`), `:263` (`initialAttempts`) and `:228` (`bindOn`). + - There are two `ReconnectLoop`s: one outside any session for `fromStart`, and one inside each session. Each attempt builds and discards a whole `Session`. + - `bindOn` relies on `close()` reaching `stop()` before its first await (the comment at `:249`). That ordering invariant lives in `session.ts:300` (`drain` calling `apply('stopping')` synchronously). + - The comment named the ordering rule; checking it held took a look at `session.ts`. + 7. **`DlrMerger.open`/`close`**: `src/dlr-merger.ts:150` and `:165`. + - `close()` means "delete, then mark spent". It runs on completion, on expiry, on eviction and on a reused base, and the delete-then-set on `spent` is only there to refresh its deadline. + - A second `ExpiringGroups` used as a tombstone set is clever but not named as one. The class doc resolved it. + 8. **Server hook composition**: `src/server.ts:208` (`handleRequest`) with `src/incoming-requests.ts:89` (`handle`) and `:147` (`unhandled`). + - What happens to a rebind or a pre-bind `unbind` is decided half in each file, through `false` return values. `boundAs === undefined` makes `bindAllows` return true. + - I resolved it by reading both. A smaller cost of the same kind: `pdu.ts:249` passes `sm_length` as the `length` argument to every wire type's `read`, and only `buffer` uses it. That implicit coupling depends on wire order. + +2. **The unit I would least want to modify: `OutgoingRequests`** (`src/outgoing-requests.ts:70-168`) + - It has three entry points: `request`, `requestPastDrain` and `requestOnCurrentLink`. Each skips a different subset of checks (drain refusal, waiting for a link, the window). + - Every sender in the library picks one of them by name: `enquire_link`, `sendSms`, receipts from `sendDlr`, bind and unbind. + - The retry recursion in `carry` depends on `LinkLife.awaitsNextLink()`, `pending.settle` ordering, and window release in `finally`. + - Whether a request may be resent (goal 2) is decided here, and it hinges on the `retryOnNextLink` flag. A wrong edit silently duplicates billed traffic. + +3. **Expected hard, found easy** + - The codec: `defs/types.ts` wire types, `PduFramer`, and `parseTlvs`/`writeTlvs` with the typed `Tlvs`. + - GSM 03.38 escaping, `encodingByDataCoding`, receipt text parsing (`dlr.ts`), `sms-id.ts` normalisation, `ReconnectLoop` backoff, and `udh.ts` IE walking. + - Each is self-contained, total, and commented at the exact surprising line. + +4. **Prose debt** + - **Needed**: + - The README "Shutdown" and "Sends and the link" sections, to confirm the drain semantics I was reverse-engineering. They cost a scroll through a 773-line README. + - The charter's decision index. The titles hinted at intent ("One owner decides whether a link can carry a request, and a bind is what makes it one"), but I was forbidden from `decisions.md`, so several stayed claims I could only check against code. The one above contradicts LinkLife starting `up`. + - The GSM-unpacked section in the charter, to trust `segmentUnits` (`message.ts:343`). + - **Defaults live in five places**: `client.ts:45`, `server.ts:403`, `session-options.ts:74`, `reconnect-loop.ts:5` (`backoffDefaults`) and `reassembly.ts:45` (`defaultMaxOctets`, duplicated by `maxHeldOctets`). "Where is the default of X" is a grep. + - **Told me nothing new**: + - The charter's architecture table mostly restates file names. + - Its long paragraph on test conventions is irrelevant to `src/`. + - `session.ts:201` ("Sends a request and resolves with the peer's response") restates the code. + - `reconnect-loop.ts:47` ("Read through a method: stop() can land while an attempt is awaiting") hides its real reason: it defeats TS narrowing. + - `client.ts:101` and `reconnect-loop.ts:84` and `:140` re-implement `errorFrom` inline. + +5. **Scores** + + The problem's intrinsic difficulty is high: an interop-heavy protocol, reconnect, and correct-accounting shutdown. It gets no bonus here. + + - **Navigation: 7.** Against "Predictable": file names and the charter's table put a symptom in the right file first try. What keeps it from 8 is that defaults and the shutdown rules are spread across five files each. + - **Locality: 5.** Against "Honest middle": LinkLife's phase and generation are read from Session, OutgoingRequests, IncomingRequests and HeldMessages. Understanding shutdown or a held message means holding 4–5 files and a synchronous-ordering invariant. + - **Shape: 6.** Between 5 and 7: most names tell the truth. The named lies are the `up` phase on an unbound socket, `'ASCII'` for GSM 03.38, "bound" meaning two things, `DlrMerger.close` meaning "tombstone", and three near-synonym request methods. Session's constructor fans out to nine collaborators. + - **Self-sufficiency: 7.** Against "Predictable": the one-line why-comments at the surprising lines resolved nearly every question. I needed the README only for the drain semantics, and the decision titles were claims I could not verify inside the code. + - **Overall: 6.** Capped by locality at 5 + 1. The hard parts are marked but not localized: they are the problem's own difficulty, spread across collaborators that share link state. + +SCORES nav=7 loc=5 shape=6 self=7 overall=6 + +## Draft B, architect seat + +**Comprehension panel: Architect, inherited. @larvit/smpp, draft-b, whole project** + +Process note: I read `AGENTS.md` in the same call as `README.md` during step 1, so the map below was not formed from the README and file tree alone. I opened no test file. + +## 1. Map from the README and the file tree (verbatim) + +Top-level areas I believe exist: +1. **Public surface**: `index.ts`. +2. **Endpoints**: `client.ts` (connect, bind, reconnect-from-start) and `server.ts` (listener, auth, bind answering). +3. **Session core**: `session.ts`, `session-options.ts`, `bind-direction.ts`. +4. **Link lifecycle**: `link-life`, `link-timers`, `reconnect-loop`, `pdu-transport`, `drain` (shutdown?). +5. **Outbound requests**: `outgoing-requests`, `pending-requests`, `send-window`, `send-sms`, `unanswered-error`. +6. **Inbound messages**: `incoming-requests`, `sms.ts` (the handle), `held-messages`, `reassembly`, `concat`, `udh`, `message-body`. +7. **Receipts**: `dlr`, `dlr-merger`, `sms-id`. +8. **Codec**: `pdu`, `pdu-framer`, `pdu-refusal`, `retained-pdu`, and `defs/*` as pure spec tables. +9. **Text**: `message.ts` (encode, split, `smppTime`) and `defs/encodings`. +10. **Plumbing**: `result`, `log`, `error-from`, `uuid`, `expiring-groups`. + +Names that do not give their purpose: +- `drain.ts` +- `retained-pdu.ts` +- `expiring-groups.ts` +- `error-from.ts` +- `link-life` vs `link-timers` +- `outgoing-requests` vs `pending-requests` vs `send-window`: three names for "requests we sent" +- `held-messages` vs `reassembly`: both "held" inbound state + +Expected from the README but not there: the goal-9 store interface. The README says it has not shipped, so that costs nothing. `interop-tests/` and `benchmarks/` sit outside `src`/`test`. + +## 2. Where the map was wrong, and what each correction cost + +| Correction | Cost | +| --- | --- | +| `defs/` is not just tables. `defs/types.ts` (684 lines) and `defs/tlvs.ts` are half the wire codec: read, write and size for every field, plus the TLV stream. `pdu.ts` is only the envelope and the body rules. | Moderate. "Where is a field written" lands in `defs`, not `pdu`. | +| `drain.ts` also holds `IdleWaiters`, which `SendWindow` and `HeldMessages` import. The send window depends on the shutdown module. | Low, but it breaks the "which way do imports point" picture. | +| `held-messages` is not a reassembly buffer. It is the inbound messages the *application* has not answered yet. | Moderate: a name-level misread. | +| `link-life` is liveness plus a queue: the phase state machine, and where a request waits for the next link. | Low. | +| Bind handling is not in the session. `server.ts` does it through the `onRequest` hook (`handleRequest`), and `client.ts` has its own `bind()`. | Moderate: two homes for one protocol step. | +| `udh.ts` reads UDHs. Writing one is hand-rolled in `message.ts:114` (`0x05,0x00,0x03,…`), and the outbound reference counter `ConcatReference` sits in `udh.ts`. | Low. | +| `decodeSegments` lives in `reassembly.ts` but is used by `sms.ts`. | Low. | +| `session.ts` is thinner than I expected (424 lines): a coordinator, not a god class. | Pleasant surprise. | + +## 3. Fan-out, level by level + +- **L0, the README:** about 10 areas. Fine. +- **L1, `src/`:** 37 flat files plus `defs/` (7). **This is the worst level.** Nothing in the layout groups the 10 areas, so only filenames and the `AGENTS.md` list stand in for directories. +- **L2, `session.ts`:** 10 collaborators (DlrMerger, HeldMessages, IncomingRequests, LinkLife, LinkTimers, OutgoingRequests, PduTransport, ReconnectLoop, ConcatReference, drain), plus 6 LinkEffects and 5 LinkEvents. At the edge but legible. +- **L3:** + - `IncomingRequests`: 8 (Reassembler, DlrMerger, HeldMessages, Session, bind-direction, dlr, concat, sms-id). + - `OutgoingRequests`: 4. + - `HeldMessages`: 5. + - `server.ts`: about 6. + - `client.ts`: about 8 local functions and a second `ReconnectLoop`. +- **`defs/`:** 7. Fine. + +## 4. Names + +**One name over two concepts:** +- **"unanswered"** means inbound messages the application has not answered (`drain.ts:52` `messagesUnanswered`, README "Unanswered messages"). It also means our requests the peer did not answer (`UnansweredError`, `SendSmsResult.unanswered`, `SendDlrResult.unanswered`). The drain waits on both kinds in one function. +- **"held"** means `HeldMessages` (inbound, unanswered by the app). It also means a request waiting for a link: `link-life.ts:191` "holding a request until a link is back", and `outgoing-requests.ts:119` `const held = await waitForLink()`. +- **`answered`** in `createSms` (`sms.ts:81`) is a mutable `{ smsId }` box, while `handlers.answered` is the release callback. They are two things on adjacent lines. + +**Names that mislead:** +- `EncodingName 'ASCII'` is GSM 03.38. +- `drain.ts` houses the general `IdleWaiters`. +- `defs/` houses the codec. +- `ExpiringGroups` expires nothing itself. Its own doc at `expiring-groups.ts:19` says owners sweep and only `weigh()` evicts. +- `session-options.ts:63` documents `shutdownTimeout` as "how long a drain waits for the requests already on the wire". It also bounds the messages half. That comment is false on exactly the 3am path. + +**One concept, many homes:** +- **Defaults live in five places:** `client.ts:45`, `server.ts:403` (idleTimeout 40 000 here vs 2 × enquireLink in the client), `session-options.ts:74`, `reconnect-loop.ts:5` `backoffDefaults`, and `reassembly.ts` `defaultMaxOctets`. The last duplicates `defaults.maxHeldOctets` (same 64 MiB). +- **Two near-identical collectors:** `send-sms.ts:274` `collectSent` and `sms.ts:188` `collectReceipt`. +- **Five concat names:** `Concat`, `ConcatInfo`, `concatOf`, `concatInfo`, `ConcatReference`, spread over `concat.ts` and `udh.ts`. + +## 5. What I would restructure, ranked + +1. **Group `src/` into about five directories:** `link/`, `outbound/`, `inbound/`, `codec/`, `receipts/`. The seams already exist in the imports; only the layout hides them. +2. **Give defaults one home:** a single `defaults` module, with the client/server differences expressed as named overrides. +3. **Split "unanswered" into two words**, for example "unreleased" for app-side messages and "unanswered" for peer-side requests. Move `IdleWaiters` out of `drain.ts`. +4. **Put UDH read and write in one file,** together with `ConcatReference`, and move `decodeSegments` next to `messageOctets`. +5. **Rename `defs/types.ts`** to what it is, the wire field codec, or move it beside `pdu.ts`. + +**What the structure gets right:** +- `LinkLife.transition()` is one explicit table returning effects, and `Session.run` (`session.ts:269`) is the only interpreter of them. +- Collaborators take narrow option objects. +- The result-everywhere rule is applied uniformly. +- `drain()` names its two halves, and `report()` logs which half was left over. +- Comments cite the SMPP section or the operator that forced each odd rule. + +## 6. The 3am question + +A graceful shutdown hangs until `shutdownTimeout` although `sendResp()` was called on everything. + +**Route, cold:** `Session.close` (`session.ts:254`) → `Session.drain` (`session.ts:300`) → `drain()` (`drain.ts:96`). That took about 3 minutes, because the filename and the function name agree. Deciding which half took about 10 more minutes: +- With every `sendResp` done, `HeldMessages.idle` should settle. Release happens at `held-messages.ts:170` via `sms.ts:159`. +- So the right unit is the second half: `OutgoingRequests.idle` (`outgoing-requests.ts:109`) → `SendWindow.idle`/`unfinished` (`send-window.ts:65`). +- That means a request of ours is still in flight. Typically it is the `sendDlr()` receipts that `requestPastDrain` let through, or a heartbeat `enquire_link` the peer is not answering. +- Each such request is bounded by `responseTimeout` (30 s), which is longer than `shutdownTimeout` (5 s). + +**Second suspect:** `sendResp` returned `err` because the write failed, and the application ignored it. The message is then never released (`sms.ts:159` releases only on success). + +The log lines `drain - shutting down with requests unfinished` and `drain - shutting down with messages unanswered` separate the two. The false comment at `session-options.ts:63` costs a detour. + +**Where it rots first:** +- **`HeldMessages`:** six numbered exits. A listener count taken via `listenerCount('sms')` at `keep()` (`held-messages.ts:154`) and decremented through `captureRejections` routed from `session.ts:97`. A `WeakMap` keyed on the handle's identity, and a back-reference to the half-constructed `Session` (`session.ts:137`). +- **The link-generation checks:** repeated in `sms.ts` (`lostLink`), `incoming-requests.ts:90/97` and `held-messages.ts:95`. Each is a local copy of one invariant. + +**Where the next two features land:** +- **Goal-9 store:** behind `ExpiringGroups`, whose three owners (`DlrMerger`, `Reassembler`, `HeldMessages`) each use a different subset of its semantics: sweep callbacks, `weigh()` eviction, `full`. A store has to replicate all three contracts. +- **Per-PDU rate-limit hook (goal 7):** `OutgoingRequests.carry` (`outgoing-requests.ts:114`), between `window.acquire` and `attempt`. That place is clean and local. +- A custom alphabet would instead ripple through the closed `EncodingName` union: `message.ts:14` `segmentUnits`, `dataCodingByEncoding`, and the `send-sms` checks. + +## 7. Hardest places, ranked + +1. **`src/held-messages.ts:40`, `HeldMessages`:** `offer` :94, `listenerRejected` :117, `keep` :154, `release` :170. It has six exits, identity-keyed release, a listener-count countdown, and is coupled to the `Session` emitter. +2. **`src/outgoing-requests.ts:70/85/101`, `request` / `requestPastDrain` / `requestOnCurrentLink`:** three entry points that differ in which gates they skip (drain refusal, link wait, window). The bind bypass is buried inside `requestPastDrain`, and the `isStopping() && canCarry()` gate needs a second read. +3. **`src/link-life.ts:74`, `LinkLife.transition`,** with `lose` :148 and `end` :159. A `stopping` flag orthogonal to the phase, `'bound'` while stopping turning into a loss, and effect order that matters in `Session.run` (`session.ts:269`, where `dropLink` clears four stores). +4. **`src/drain.ts:70/96`, `answeringBudget` and `drain`:** `0` means forever except for the messages half, plus a `setImmediate` turn whose job is to catch a receipt issued right after the last answer. +5. **`src/reassembly.ts:188`, `Reassembler.trim`,** with `collect` :111. The eviction arithmetic (`parts.size - 1` when the current group is itself evicted) is correct but has to be derived. +6. **`src/expiring-groups.ts:19/70`, `ExpiringGroups.weigh`:** it evicts, `set` does not, and owners must sweep. Its callers' correctness depends on remembering which. +7. **`src/pdu.ts:84/113`, `resolveShortMessage` / `resolveBody`:** which field the `data_coding` describes, and when detection may overwrite it. +8. **`src/client.ts:263/286`, `initialAttempts` / `keepTrying`:** a second `ReconnectLoop`, with a fresh `Session` per attempt, for `fromStart`. + +**Single unit I would least want to modify:** `HeldMessages` (`src/held-messages.ts`). + +## 8. Intrinsic difficulty + +This is an SMPP session layer with reconnect, graceful drain, bounded reassembly, receipt merging and a hand-rolled wire codec. The problem itself is moderately high in difficulty, and gets no bonus in the scores. + +## 9. Scores + +- **Navigation 7:** matches the "predictable" anchor. Descriptive filenames and function names took me from the symptom to `drain()` in minutes, and `report()` logs which half stalled. The flat 37-file `src/` and the false `shutdownTimeout` comment keep it from 8. +- **Locality 6:** between "honest middle" and "predictable". `HeldMessages` and `IncomingRequests` call back into `Session` (emit, `sendReturn`, `close`), the link-generation invariant is copied into three places, `Session.run` effect order is load-bearing, and defaults live in five files. +- **Shape 6:** between the anchors. L2 and L3 fan-out is bounded and most names tell the truth. `src/` fans out to 37 files, and "unanswered", "held", `'ASCII'`, `drain.ts` and `defs/` each name something other than what they hold. +- **Self-sufficiency 7:** the "predictable" anchor. Invariants are stated at the code (ExpiringGroups' contract, the six exits, SMPP section citations), and I did not need a second document open. It misses 8 on one false option comment and a few budget rules (`answeringBudget`) that need rereading. +- **Overall 6:** capped at the lowest dimension plus one (7). It sits at 6 because the hardest code (the held-message flow and outbound gating) is exactly where locality is weakest. + +SCORES nav=7 loc=6 shape=6 self=7 overall=6 diff --git a/docs/comprehension-rewrite/panels/round-2-drafts-c-d.md b/docs/comprehension-rewrite/panels/round-2-drafts-c-d.md new file mode 100644 index 0000000..775bc98 --- /dev/null +++ b/docs/comprehension-rewrite/panels/round-2-drafts-c-d.md @@ -0,0 +1,658 @@ +# Round 2: drafts C and D + +## Draft C, junior seat + +1. **Hardest places, ranked hardest first** + + 1. **`src/session.ts:336-404` (`drain`, `dropSocket`, `linkLost`, `end`), `comeBackUp` at `:303`, and `src/session/link-life.ts:17-82` (`LinkLife`'s phase plus its predicates).** Link state is a four-value `Phase` plus a separate `stopped` flag. Seven predicates read it: `isAttached`, `isUp`, `isOver`, `isStopped`, `retrying`, `awaitsNextLink`, `refusal`. On top of that, `OutgoingRequests.canCarry()` adds `!sock.destroyed`. To follow `comeBackUp`'s `!this.link.retrying() || !this.link.isUp()` (`:319`) or `drain`'s two `canCarry()` checks, I had to hold every phase transition at once. `linkLost` is order-dependent ("Read first: a `disconnected` listener may close() the session"), so a synchronous listener re-enters the session in the middle of the method. The comments resolved each line on its own. The whole state machine never became clear to me; I would need to draw it. + + 2. **`src/session/outgoing-requests.ts:83-150` (`request`, `carry`, `attempt`).** The three lanes, the `for(;;)` retry loop, `window.release()` in a `finally`, and `pending.wait()` registered before `write()` all interact. The loop-exit comment at `:118` ("Until the link is dropped it admits the retry straight back onto the dead socket, and the loop spins") took three rereads. The `Lane` doc comment at `:20-26` rescued the lanes. The retry exit stayed half-opaque. + + 3. **`src/session/incoming-requests.ts:103-271` (`handle`, `route`, `onDelivery`, `onMessage`, plus `refusedSegmentStatus` at `:28`).** This is where the domain costs the most. `data_sm` routes by `carriedAs`. `deliver_sm` might be a receipt or a message, and `onDelivery` falls through to `onMessage`. The status codes come as a family: `ESME_RX_P_APPN`, `ESME_RX_T_APPN`, `ESME_RTHROTTLED`, `ESME_RINVTLVVAL`, `ESME_RINVESMCLASS`. The `arrivedOn` socket-identity check at `:111` is state compared across an `await`. The flow reads cleanly, but I only knew why each status was right after reading README "Receiving in depth" and "Server in depth". + + 4. **`src/messages/reassembly.ts:110-206` (`Reassembler.collect`, `trim`) with `src/messages/expiring-groups.ts:70-88` (`weigh`).** `weigh()` can evict the group currently being added to. `trim` then counts `parts.size - 1` for that group, because the segment just added is refused and so is not lost. The contract is split across two classes: `ExpiringGroups` "enforces neither max nor timeout itself", so every owner must remember to call `full` or `takeExpired`. It resolved after reading the `ExpiringGroups` doc comments. Action at a distance, but it is marked. + + 5. **`src/messages/dlr.ts:157-238` (`messageType`, `receiptStatus`, `dlrFromPdu`).** A four-value `MessageType` is derived from `esm_class` bits and then from a TLV. The TLV state and the body's state take precedence over each other in different ways. `statusId` falls back to `UNKNOWN`, and an `unmarked` PDU without both an id and a state is not a receipt. The comments cite spec sections (5.3.2.26, Appendix B) that I cannot open. The README `Dlr` field table is what finally made the output shape make sense. + + 6. **`src/client.ts:219-322` (`bindOn`, `initialAttempts`, `keepTrying`).** There are two routes into `ReconnectLoop`: the session's own, and a second one here that builds a fresh `Session` per attempt. There is closure state (`lastErr`, `settled`) and abort-listener bookkeeping. The comment at `:241` ("close() must reach the loop's stop() before its first await") depends on how `Session.close` is ordered inside, in another file. It stayed partly opaque. + + 7. **`src/wire/pdu.ts:84-136` (`resolveShortMessage`, `resolveBody`).** `CodingSource` decides whether `short_message` or `message_payload` owns `data_coding`. The cases are Buffer or string, empty or not, crossed with a string TLV being present. That is four or more branches returning objects that are almost the same. The `CodingSource` doc comment helped, but only after I had read `messageOctets()` in `message-body.ts`. + + 8. **`src/defs/encodings.ts:151-190` (`messageClassOf`, `messageClassEncoding`, `encodingByDataCoding`).** Bit masking over GSM 03.38 coding groups, which I had no context for. `messageClassEncoding` has a `//` comment and a `/** */` comment stacked on top of it that say different things. It stayed opaque, and I would trust the tests over my reading. + +2. **The unit I'd least want to modify:** `Session.linkLost`, `dropSocket` and `end` (`src/session.ts:366-404`) together with `LinkLife`. They are re-entered from the transport (close, error, unreadable), from `LinkTimers.onIdle`, from `comeBackUp`, from `unbind` and `close`, and synchronously from application listeners (`disconnected`, `close`). The correctness depends on call order and on idempotence guards (`drop()` returning false, `end()` returning false). None of that is visible at any single call site. + +3. **Expected hard, found easy:** + - The codec in `defs/types.ts`: 684 lines, but it is one pattern repeated (read, size, write, all returning Results). + - `PduFramer`. + - The no-throw `Result` convention, which is applied the same way everywhere. + - `send-sms.ts`: `checkOptions` is a flat chain of guards. + - `bind-direction.ts`. + - The folder layout. The AGENTS.md architecture map matched the files one to one, and a one-line purpose per file made cold navigation fast. + +4. **Prose debt** + - **What I needed and what it cost:** + - The basic domain vocabulary: ESME vs SMSC/MC, bind types, what `deliver_sm` doubles as, `esm_class`, `data_coding`, UDH vs `sar_*`, `registered_delivery`. There is no glossary anywhere. I rebuilt it from README "Receiving in depth", "Delivery receipts" and "Bind direction", which cost a pass over a 764-line README with a lot of jumping. + - The AGENTS section "GSM 7-bit is sent unpacked", which was essential for `segmentUnits` in `message.ts`. + - Many code comments cite SMPP section numbers with no summary, so they are pointers I could not follow. + - The AGENTS decision index names choices, for example "Every request leaves through one `request()`, in one of three lanes", whose reasoning is in `docs/decisions.md`, which was out of bounds. The titles alone helped a little. + - I did not open any test. + - **Prose that added nothing the code didn't already say:** + - The seven `declare` listener lines in each emitter class. + - `OutgoingRequests.deliver` ("False means nothing was") and `PendingRequests.deliver`, both restating their code. + - README's "Everything exported" table, which repeats `index.ts`. + - The AGENTS Conventions paragraph about test fixtures, for reading `src/`. + - The 0.4.0 defect table, which is history and says nothing about the current code's structure. + +5. **Scores** + - **Navigation: 7.** It sits at "predictable": the `session/`, `messages/`, `wire/`, `defs/` split plus the per-file map in AGENTS.md got me from a symptom to a file on the first try. It stays below 8 because concatenation logic is split three ways (`concat.ts`, `udh.ts`, `reassembly.ts`), refusal statuses are split between `pdu-refusal.ts` and `incoming-requests.ts`, and `udh.ts` holds `ConcatReference`. + - **Locality: 6.** It is between "honest middle" and "predictable". The hard parts are marked, but `IncomingRequests` holds the `Session` and calls back into it (`emit`, `sendReturn`, `close`, `sock`, `linkEnd`). `LinkLife` is shared by `Session` and `OutgoingRequests`. `ExpiringGroups` depends on its owners to sweep. `linkLost` is re-entrant through listeners. + - **Shape: 6.** Files are small and fan-out is bounded, but some names mislead: + - The GSM7 codec is called `ascii`. + - `ExpiringGroups` is used as a "spent" set. + - `linkLost()` means something different in `Session`, `OutgoingRequests` and `IncomingRequests`. + - `refusal()` returns an `Error` on `LinkLife` and an `ErrorName` on `IncomingRequests`. + - `IdleWaiters.settle()` wakes waiters whatever the count reads. + - **Self-sufficiency: 5.** Honest middle. The comments are dense and often state invariants. But for a reader with no SMPP background, the domain terms and bare spec citations mean the README has to stay open beside `incoming-requests.ts`, `dlr.ts` and `encodings.ts`. + - **Overall: 6.** Capped at self-sufficiency plus one. The layout and conventions are clearly cared for; what costs a junior is the lifecycle state machine and the unexplained domain. + - **Intrinsic difficulty:** high. It is an asynchronous protocol session with reconnect, drain, windowing and reassembly under hostile input. That gets no bonus in the scores above. + +SCORES nav=7 loc=6 shape=6 self=5 overall=6 + +## Draft C, mid seat + +1. **Hardest places, hardest first** + +1. **`src/session.ts:303-404`: `Session.comeBackUp` / `drain` / `dropSocket` / `linkLost` / `end`.** Whether the link is alive is held in four places: + - `LinkLife.phase`, which is `binding`, `up`, `down` or `ended` + - `LinkLife.stopped` + - `ReconnectLoop.halted` + - `transport.sock.destroyed` + + Order matters in several spots, and only comments say so. `linkLost` reads `retrying()` before the drop because a `disconnected` listener may call `close()`. `comeBackUp` checks `!retrying() || !isUp()` after `bind()`. That only makes sense once you find that `bind()` reaches `session.bound()`, which calls `link.open()` out of sight. `canCarry()` (`outgoing-requests.ts:58`) asks both `link.isUp()` and `sock.destroyed`, and nothing explains why both are needed. The comments helped, but I had to trace the state by hand and it is still not fully clear to me. +2. **`src/session/outgoing-requests.ts:83-150`: `OutgoingRequests.request` / `carry` / `attempt`.** The loop in `carry` has three waits: the link budget, a window slot, and the response. The window slot is released in a `finally`, and a retry is allowed only when `attempt.retry && link.awaitsNextLink()`. You need LinkLife's phase logic in your head (`link-life.ts:71-82`) to see why the loop ends. The comment "the loop spins" warns about it but does not explain it. The three lanes are well documented in the `Lane` type's comment (line 25). This resolved, slowly. +3. **`src/wire/pdu.ts:84-136`: `resolveShortMessage` / `resolveBody`.** The code decides whether `short_message` or `message_payload` owns `data_coding`. An empty Buffer means `message_payload`, and a string body gets encoded and can overwrite `data_coding`, but only for one of the two sources. I had no domain context and read it three times. The `CodingSource` comment helped, and the README "Building" bullets confirmed what it is meant to do. +4. **`src/session/incoming-requests.ts:103-271`: `handle` / `route` / `onMessage` / `refusedSegmentStatus`.** A `data_sm` becomes `submit_sm` or `deliver_sm` depending on `linkEnd` (via `standsInFor`). A `deliver_sm` that is not a receipt falls through to `onMessage`. The status codes (`ESME_RX_T_APPN`, `ESME_RTHROTTLED`, `ESME_RINVTLVVAL`, `ESME_RINVESMCLASS`) are domain rules I cannot check. The class also calls back into `Session` through `sock`, `emit`, `sendReturn` and `close`. The socket-identity check after the `onRequest` await was clear once the comment was read. The status choices stayed opaque; I trusted README "Receiving in depth". +5. **`src/messages/reassembly.ts:110-206` with `expiring-groups.ts:70-88`: `Reassembler.collect` / `trim` and `ExpiringGroups.weigh`.** The rules are split between the two classes: + - ExpiringGroups enforces neither max nor timeout, and its owners must. + - `set()` resets weight to zero. + - `weigh()` may evict the key you are weighing. + - `trim` works out `answered = parts.size - 1` for the current group. + + The comments state each rule, but I needed all of them at once. This resolved. +6. **`src/messages/dlr-merger.ts:105-185`: `DlrMerger.collect` / `open` / `spend`.** There are two stores (`groups` and `spent`), and `spend()` is the exit for four different situations: completion, expiry, reuse and eviction. The class docblock and the `severity` comment explain it. This resolved. +7. **`src/defs/encodings.ts:162-190`: `messageClassEncoding` / `encodingByDataCoding`.** This is bit-level decoding of `data_coding`. The comments say which bits are which but not the table behind them. The code is short, but it stayed opaque without the GSM 03.38 spec. +8. **`src/client.ts:220-322`: `bindOn` / `initialAttempts` / `keepTrying`.** A second `ReconnectLoop` is built outside the session for `fromStart`. `bindOn` depends on `close()` reaching `stop()` before its first await, which the comment at line 241 states. There are also two abort listeners with different lifetimes. This resolved with effort. + +I did not open any tests. + +2. **Unit I would least want to modify:** the session lifecycle cluster, `Session.linkLost` / `dropSocket` / `end` / `comeBackUp` together with `LinkLife`. Every change there interacts with the reconnect loop's own stopped flag, with the drain's `canCarry()` checks before and after it waits, and with whatever an event listener does during `emit`. Tests would be the only way to know a change is safe. + +3. **Expected hard, found easy:** + - `PduFramer` + - the parse path in `pduToObj`/`parsePdu` + - `PendingRequests` + - `SendWindow` + - `ReconnectLoop` backoff + - the listener guards that stop an application listener's throw or rejection from escaping (the no-throw rule) + - the `defs/` tables + - `defs/types.ts`, which is 684 lines but repetitive and predictable + + The rule that nothing throws makes every call site easy to read. + +4. **Prose debt** + - **Needed:** + - README "Shutdown" and "Sends and the link", to understand the lanes and the drain. + - README "Receiving in depth", for which way a `data_sm` goes and the throttle statuses. + - The AGENTS architecture map, which is accurate and was the best way in. + - The AGENTS decision index lines, such as "Every message is answered on arrival" and "One owner decides whether a link can carry a request". They gave intent cheaply because each is one line. + - **Cost to find:** low, because the map and the index point to the right places. The code's references to spec sections (e.g. "SMPP 3.4 5.2.19") assume a document I do not have. + - **One false claim:** AGENTS says "`wire` uses `defs`, `messages` uses `wire`", but `wire/pdu.ts:10` imports `decodeMessage` and `encodeBody` from `messages/message.ts`. So wire and messages depend on each other. + - **Told me nothing:** + - the AGENTS 0.4.0 defect table (history, not needed to read the current code) + - most of the test-convention prose in AGENTS + - the README feature bullets + - one-line docstrings that restate the method name, such as `isStopped` and `get sock` + - **Duplicated code:** `quoted()` is duplicated in `bind-direction.ts` and `session-options.ts`. `collectSent` and `collectReceipt` are near-copies. + +5. **Scores** + - **Navigation: 7 (Predictable).** The AGENTS file map matches the layout, and the `session/` / `messages/` / `wire/` grouping took me straight to the right file. It falls short of 9 because answering a request is split across `server.handleRequest`, `IncomingRequests.unhandled` (`ESME_RALYBND`) and `Session.refuse`. + - **Locality: 6 (between 5 and 7).** The collaborators are small and injected. But link liveness is spread over `LinkLife`, `ReconnectLoop.halted` and `sock.destroyed`, and three comments carry order rules: "Read first", "must reach stop() before its first await", and "the loop spins". `IncomingRequests` also reaches back into `Session`. + - **Shape: 7 (Predictable).** Fan-out at each level is bounded, and the `Session` constructor wires seven named collaborators. Some names mislead: + - the GSM codec is called `ascii` (`encodings.ts:45`) + - `idle` means three different things: `IdleWaiters`, `ExpiringGroups.idle()` and the `LinkTimers.idle` timer + - the near-synonyms `stop`, `end`, `close`, `release`, `linkLost` and `dropSocket` blur which one is final + - **Self-sufficiency: 7 (Predictable).** Almost every non-obvious branch carries a one-line reason, often with a spec section. The lanes, the drain and the refusal statuses still needed the README open beside the code. + - **Overall: 6.** It is capped by Locality. The lifecycle corners are marked, but they are not contained. + + The problem is hard in itself: a protocol full of peer quirks, plus async lifecycle with reconnect and drain. That gets no bonus here. + +SCORES nav=7 loc=6 shape=7 self=7 overall=6 + +## Draft C, senior seat + +1. **Hardest places, ranked** + + 1. **`src/session.ts:303-404`, the `Session` lifecycle: `comeBackUp`, `drain`, `stop`, `dropSocket`, `linkLost`, `end`.** Whether the session is still alive is spread across three places: `LinkLife`'s phase and its separate `stopped` flag, `ReconnectLoop.halted`, and `link.end()`. To follow any one of these methods I had to hold all three. `comeBackUp:319` tests `!retrying() || !isUp()`. That only makes sense once you know `isUp()` got set as a side effect: the `onConnected` callback in `client.ts:bind` calls `session.bound()`, which calls `link.open()`. The ordering is load-bearing in several places. `linkLost:379` has to read `retrying()` before the drop. `end()` calls `dropSocket()`, which is also a guard. `client.ts:241` says "close() must reach the loop's stop() before its first await". The comments at the call sites marked each ordering rule. None of them explained why the whole thing is split this way. It stayed expensive to read. + 2. **`src/session/outgoing-requests.ts:83-121`, `request` / `carry`, together with `src/session/link-life.ts:34-186`.** `LinkLife` exposes six overlapping predicates: `isAttached`, `isUp`, `isStopped`, `retrying`, `awaitsNextLink` and `refusal`. The lanes mix them. `message` checks `refusal() ?? isStopped()`. `receipt` skips both checks up front and only meets `refusal()` inside `budget()`. `link` skips everything. The loop exits on `!attempt.retry || !awaitsNextLink()`. The comment about it spinning helped, but I had to walk the phase transitions by hand to convince myself. The `Lane` doc comment resolved what each lane is for. It did not resolve what each lane actually checks. + 3. **`src/messages/expiring-groups.ts:18`, `ExpiringGroups`, with `src/messages/reassembly.ts:110-136, 187-206`, `Reassembler.collect` / `trim`.** The store says outright that it enforces neither its `max` nor its timeout itself. The owner has to check `full`, call `takeExpired()`, and let `weigh()` evict, and `weigh()` can evict the very group being written. In `trim`, `answered = size - 1` for the current key, and the refused segment has already been `set` into the group. That is an invariant spread across two files. The doc comments state the contract honestly, so it was readable, just slow. + 4. **`src/wire/pdu.ts:84-136`, `resolveShortMessage` / `resolveBody`.** Deciding which field is allowed to set `data_coding` goes through `CodingSource`. An empty Buffer `short_message` counts as source `message_payload`. A string body rewrites `data_coding`, but only when it encodes to something non-empty. Three branches return three different param shapes. The `CodingSource` doc comment is the key, and I only understood it after going back to `message-body.ts`. + 5. **`src/messages/dlr-merger.ts:68-184`, `DlrMerger` (`open`, `spend`, `dropOldest`).** It keeps a second `ExpiringGroups` as a tombstone set. `spend()` is called on completion, on expiry, on eviction and on id reuse, and each time it deletes and re-inserts the tombstone. The class doc comment explains why ("merged at most once"). I still had to trace which paths end up in `spend` to be sure a straggler receipt is ignored. + 6. **`src/client.ts:219-322`, `bindOn` / `initialAttempts` / `keepTrying`.** For `fromStart` there are two `ReconnectLoop`s: one in the client and one inside each session. `lastErr` lives in a closure, and `settle` is idempotent. `bindOn` removes its abort listener on failure but deliberately keeps it after success, so a later abort closes a bound session. Only README ("That signal also closes the session once bound") told me that was intended rather than a leak. + 7. **`src/defs/encodings.ts:483-514`, `messageClassEncoding` / `encodingByDataCoding`.** Bit masks over coding groups I had no background in. There is an orphan `//` comment sitting above a `/** */` doc comment. The GSM 03.38 codec is named `ascii`, and "fall back to ASCII" (line 504) actually means GSM7. That misled me until I checked the `encodings` map. + 8. **`src/session/incoming-requests.ts:103-153`, `handle` / `route`.** `arrivedOn` is captured before the application's `onRequest` await and compared afterwards. The `unbind` case closes the session with `AbortSignal.abort()` from inside a handler that the dispatch itself is running. Both have comments, and both still needed a second read to be sure nothing re-enters. + +2. **Least want to modify:** the `Session` lifecycle cluster (`session.ts:303-404` plus `LinkLife`). A change to when the link counts as up, stopped or ended touches `LinkLife`, `ReconnectLoop`, `OutgoingRequests.canCarry`, `client.ts` `bindOn`/`bind`, and `IncomingRequests`' `sock` check. Nothing in the types enforces the ordering, so it only lives in the comments. + +3. **Expected hard, found easy:** `PduFramer`; the wire types in `defs/types.ts` (long but uniform); TLV read and write, including repeatable tags and keying by id; the reconnect backoff; `bind-direction.ts`; the `Result` convention; `udh.ts` `concatInfo`; `send-sms.ts` validation, which is a flat checklist; `PendingRequests`; `SendWindow`. + +4. **Prose debt.** + - **Documentation I needed:** + - README "Reconnect" section, to know the abort listener kept alive in `bindOn` is intended. + - AGENTS "GSM 7-bit is sent unpacked", to trust `segmentUnits` GSM7 153 vs UCS2 134. The one-line comment at `message.ts:13` is close to enough on its own. + - README "Shutdown", to learn that `drain()` returning `{}` when `!canCarry()` also skips waiting on handlers. The code says "nothing is on the wire", which does not mention handlers. + - The charter's decision index points at `docs/decisions.md`, which I was not allowed to open. For several decisions I had only the title and had to take the rest on trust. + - **Where the charter's map is wrong:** + - It says `messages` uses `wire`. In fact `wire/pdu.ts` imports `messages/message.ts` (`decodeMessage`, `encodeBody`). + - `decodeSegments` lives in `reassembly.ts` but is used by `sms.ts`. + - `ConcatReference`, a per-session counter, lives in `udh.ts`. + + Each of these cost a wrong turn. + - **Prose that told me nothing new:** + - `send()`'s "Sends a request and resolves with the peer's response". + - `LinkTimers`' "Keeps a quiet connection honest". + - `PduFramer`'s "Cuts a byte stream into whole PDUs". + - The charter's test conventions, which are irrelevant to reading `src/`. + - Most of the decision-index lines, which restate README behaviour. + - The 0.4.0 defect table. It is useful history but did not help me read the current code. + - **Duplicated code:** `collectSent` and `collectReceipt` are near-copies, `quoted()` exists twice, and the `emit` / `captureRejectionSymbol` guards are copied between `Session` and `SmppServer`. + +5. **Scores.** The problem is intrinsically hard (SMPP session state, reassembly under memory caps, receipt correlation), and that gets no bonus. + - **Navigation 7:** near the "predictable" anchor. The `session/`, `messages/`, `wire/`, `defs/` split and the charter's file map took me from symptom to file first try. It is held below 8 by the misplaced units above and the false import-direction claim. + - **Locality 6:** between the 5 and 7 anchors. Link lifecycle state is split across `LinkLife`, `ReconnectLoop` and `Session`, with ordering rules marked only by comments. `IncomingRequests` reaches back into `session.sock`, `boundAs` and `linkEnd`. + - **Shape 7:** at "predictable". Fan-out per level is small and most names are honest. It is held there by `ascii` for the GSM codec, "fall back to ASCII", `udh.ts` also holding the reference counter, and six overlapping liveness predicates on `LinkLife`. + - **Self-sufficiency 7:** at "predictable". Comments cite SMPP sections and state invariants beside the code. Two behaviours (the kept abort listener, the drain skipping handlers) needed README open beside the code. + - **Overall 6:** a cold senior would be productive within a week and would know to fear the lifecycle cluster. The locality cost there is what holds it below 7. + +SCORES nav=7 loc=6 shape=7 self=7 overall=6 + +## Draft C, architect seat + +**Comprehension panel report: Architect, inherited (draft-c, @larvit/smpp)** + +I opened no test file. I read every non-test source file under `src/`. I read `defs/types.ts` and `defs/tlvs.ts` by outline plus key sections, and `commands.ts`, `constants.ts` and `errors.ts` only as far as their outline. + +## 1. Map from README and file tree only, verbatim + +- **Root, the entry points:** `client.ts` has `client()`; `server.ts` has `server()` and `SmppServer`; `session.ts` has `Session`, the orchestrator; `index.ts` is the public surface. +- **Root, cross-cutting:** `result.ts` (Result), `log.ts` (SmppLog), `error-from.ts` (unknown → Error). `defaults.ts` I expect to hold session defaults, and I am unsure how it relates to `session/session-options.ts`. +- **`defs/`:** the SMPP spec tables: commands, TLVs, errors, constants, encodings, wire types. +- **`wire/`:** the codec. `pdu.ts` is pduToObj/objToPdu, `pdu-framer.ts` turns a byte stream into PDUs, `pdu-refusal.ts` is PduRefusedError. +- **`session/`:** what a Session is made of: transport, keepalive timers, reconnect, send window, pending-request correlation, incoming and outgoing requests, bind direction, options. `running-handlers.ts` I guess counts `onSms` promises (README: "1000 handlers", "close() waits for your handlers"). `idle-waiters.ts` and `link-life.ts` are unclear from their names. +- **`messages/`:** message-level logic: encode/split, UDH, concatenation, DLR parse and merge, reassembly, the inbound `Sms` handle, sendSms composition, ids, uuid. +- **Names that do not give their purpose:** `retained-pdu.ts`, `expiring-groups.ts`, `unanswered-error.ts` (why in messages/?), `uuid.ts` (why in messages/?), `pdu-transport.ts` (session, not wire?), `link-life.ts`. +- **README promises I expect to find:** a drain that waits for handlers, then for requests. `smppTime` somewhere in messages. No store (goal 9 says it has not shipped). + +## 2. Where the map was wrong, and what each correction cost + +| Map claim | Reality | Cost | +|---|---|---| +| `wire/` is the codec | The per-field codec is `defs/types.ts` (684 lines of read/size/write) and `defs/tlvs.ts` (`parseTlvs`/`writeTlvs`). `wire/pdu.ts:10` imports `encodeBody` and `decodeMessage` from `messages/message.ts`, so wire depends on messages. AGENTS.md says the reverse ("`messages` uses `wire`"). | High. I had to reopen `defs/`, and the documented dependency direction is false. | +| `defaults.ts` holds session defaults | It holds every option's default plus `bounds` (limits that are not options). | Low. | +| `session-options.ts` holds option types | It also holds `SessionEvents`, `OnRequest`, `SmsHandler`, and the validation for client and server options (`CheckableOptions` includes `authenticate`, `connectTimeout`, `fromStart`). | Medium. | +| One reconnect concept | `client.ts:278` `keepTrying` runs a second, separate `ReconnectLoop` for `fromStart`. `ReconnectOptions` (session: `connect`/`onConnected`) and the client's `reconnect` (`ReconnectTuning`) are two shapes under one name. | Medium. | +| `running-handlers.ts` counts `onSms` promises | Correct. | None. | +| `messages/` is message logic | It is a 14-file grab-bag with 5 themes: codec helpers, receipts, bounded stores, sending, ids/errors. | Medium. | + +## 3. Fan-out, level by level + +- **L0, `src/`:** 8 files and 4 directories. It mixes 4 entry points with 4 utilities. Acceptable. +- **L1:** + - `session/`: 12 files. `Session` composes 7 collaborators plus `ConcatReference`. + - `messages/`: 14 files across about 5 themes. + - `wire/`: 3 files. + - `defs/`: 7 files. +- **L2, inside `session.ts`:** about 25 members. The lifecycle cluster alone (`drain`/`stop`/`dropSocket`/`linkLost`/`end`) touches 6 collaborators. +- **Worst level:** + - By count and cohesion, `messages/` (14 files). + - By reading cost, `session/`. `link-life.ts` has 7 near-synonymous predicates, `OutgoingRequests.canCarry` is an 8th, and `ReconnectLoop.isStopped` a 9th. + +## 4. Names + +**Names that mislead** +- `encodings.ts:45` `ascii` is the GSM 03.38 codec. The comment at `encodings.ts:180` says "alphabets with no codec fall back to ASCII", but the code returns `'GSM7'`. +- `ExpiringGroups` enforces neither the cap nor the expiry; its own doc comment says so at `expiring-groups.ts:18`. +- `defs/` is described as "spec tables" but holds most of the codec. +- `udh.ts` holds the outbound `ConcatReference` counter next to UDH parsing. +- `messages/unanswered-error.ts` is used by `session/outgoing-requests.ts`. + +**Concepts with two names** +- `sendSms` and `submitSms` name the same action. +- GSM7 and `ascii` name the same codec. +- "stopped" is held twice: `LinkLife.stopped` and `ReconnectLoop.halted`, both set by `Session.stop()`. +- `smsId`, `message_id` and `base` refer to the same id. + +**One name over several concepts** +- `idle`: the `LinkTimers` idle timeout, `IdleWaiters` (a count falling to zero), and `ExpiringGroups.idle()` (stop the sweeper). +- `release`: `SendWindow.release` frees a slot, `RunningHandlers.release` wakes the drain, `LinkLife.release` settles link waiters. +- `settle`: used everywhere. +- `reconnect`: the session's `ReconnectOptions` and the client's `ReconnectTuning`. `checkReconnect` validates only the client shape. + +## 5. What I would restructure, ranked + +1. **Move the codec into `wire/`:** `defs/types.ts` read/write, `defs/tlvs.ts` parse/write, and `encodeBody`/`decodeMessage`. This makes the documented dependency direction true. +2. **Split `messages/`** into inbound, outbound and receipts. Move `unanswered-error` to `session/`, and move `uuid` out. +3. **Collapse the link predicates** in `LinkLife` into one query per lane (for example `admits(lane)`), absorbing `canCarry` and `ReconnectLoop.halted`. +4. **Split `session-options.ts`:** event and hook types in one place, client/server option checking in another. +5. **Renames:** `ascii` → `gsm7`, `ExpiringGroups` → something that says it only holds keyed deadlines, and distinct names for the `idle`, `release` and `settle` overloads. + +**What the structure gets right** +- `Session` is split into collaborators, each with a one-line owner doc. +- `Result` is used uniformly. +- Comments record why at the call site (for example `link-life.ts:173`, `reassembly.ts:162`, `dlr-merger.ts:23`). +- The AGENTS.md file map is accurate at file level. +- Every store is bounded and says so. + +## 6. The 3am question + +**Time and route, cold:** about 5–10 minutes. README "Shutdown" → `session.ts:267` `close()` → `session.ts:336` `drain()` → `incoming.idle` → `session/running-handlers.ts:54` `RunningHandlers.run` and `:89` `idle`. + +**The premise does not match this code.** `sms.sendResp()` does not exist here; a grep for it finds nothing. Every message is answered on arrival (`incoming-requests.ts:233`), and the drain waits for the promise the `onSms` handler returned to settle, not for any answer. + +**Likely cause:** a handler whose promise has not settled. The typical case is a handler awaiting `sms.sendDlr()` while the peer never answers the `deliver_sm`: `responseTimeout` (30 s) is longer than `shutdownTimeout` (5 s). The second place to look is the phase after it: `outgoing.idle` → `SendWindow.unfinished()` (`send-window.ts:82`), which counts queued waiters as well as requests on the wire. + +**Adjacent hazard (plausible, not confirmed):** `session.ts:340` returns before waiting for handlers when the link cannot carry requests. A `close()` during a reconnect gap therefore skips the handler wait, which README step 2 says always happens. A slow `onRequest` hook is never counted by the drain either. + +**Where it rots first:** the lifecycle cluster in `session.ts:336-404`. Its correctness depends on call order: `linkLost` reads `retrying()` before `dropSocket`, and `end` calls `stop` and `dropSocket` before the phase check. Every new link state adds a predicate to `LinkLife`. + +**Where the next two features land:** +- Goal 9's store cuts across `DlrMerger`, `Reassembler` and `ExpiringGroups` in `messages/`, and `RunningHandlers` in `session/`. It has no single seam today. +- A per-PDU rate limit (goal 7) becomes a fourth wait in the `OutgoingRequests.carry` loop (`outgoing-requests.ts:100`). + +## 7. Hardest places, ranked + +1. `src/session.ts:336-404`, `drain`/`stop`/`dropSocket`/`linkLost`/`end`: order dependence, and the early return that skips handlers. +2. `src/session/link-life.ts:50-82`, the `LinkLife` predicates: phase × stopped × reconnects expressed as 7 booleans. +3. `src/session/outgoing-requests.ts:83-121`, `request`/`carry`: 3 lanes and a retry loop whose own comment warns that it spins. +4. `src/session/incoming-requests.ts:103-128`, `IncomingRequests.handle`: holds a Session back-reference, awaits `onRequest`, then re-checks the socket. The session is reachable by two routes: the object and the `sendReceipt` closure. +5. `src/messages/reassembly.ts:110-206`, `Reassembler.collect`/`trim`: eviction by weight, with the `parts - 1` accounting for a segment that was refused. +6. `src/messages/dlr-merger.ts:150-173`, `DlrMerger.open`/`spend`: a second `ExpiringGroups` used as a set of spent ids. +7. `src/wire/pdu.ts:84-136`, `resolveShortMessage`/`resolveBody`: which source's `data_coding` wins. +8. `src/client.ts:250-322`, `keepTrying`/`initialAttempts`: a second reconnect mechanism. + +**The unit I would least want to modify:** the `Session` lifecycle cluster, `session.ts:366-404` (`dropSocket`/`linkLost`/`end`). + +## 8. Scores + +The problem is intrinsically hard: SMPP session lifecycle with reconnect, drain, send window and bounded reassembly. That earns no bonus. + +- **Navigation 7.** Sits at "Predictable": the AGENTS.md map and file names took me from symptom to `RunningHandlers` in minutes. It is held below 8 because the codec is split between `defs/` and `wire/`, and `messages/` is a grab-bag. +- **Locality 6.** Between "Honest middle" and "Predictable". The collaborators are real. It is held there by `IncomingRequests` holding a `Session` back-reference, by stopped/up state spread across `LinkLife`, `ReconnectLoop` and `transport.sock.destroyed`, and by the order-dependent `end`/`linkLost`. +- **Shape 6.** Between the anchors. It is held there by the 14-file `messages/`, a `LinkLife` API of 9 predicates, the wire→messages import that contradicts AGENTS.md, and names that lie (`ascii`, `ExpiringGroups`, `idle`/`release` overloads). +- **Self-sufficiency 7.** Sits at "Predictable": the invariants and whys are stated beside the code. It is held below 8 because the drain's early return at `session.ts:340` contradicts the README's shutdown contract, and the file map's layering claim is false. +- **Overall 6.** Capped at 7 by the lowest dimension plus one. It sits at 6 because the hardest code (the lifecycle and the link predicates) is exactly where the order dependence lives. + +SCORES nav=7 loc=6 shape=6 self=7 overall=6 + +## Draft D, junior seat + +1. **Hardest places, hardest first** + + 1. **`src/handled-messages.ts:92` `HandledMessages.offer`, `:125` `run`, and `src/sms.ts:83` `createSms`.** + - `offer` creates an object whose own closure sets `handled.answered`. `sendResp` then takes one of two paths depending on `answeredAs` (`sms.ts:97`). + - After a handler fails, `run` calls `sms.sendResp({ status: retryStatus })` (`:133`). For a multipart message, `answeredOnArrival()` returns an `err` there and nobody reads it. + - I had to trace three files to see that this is intended: the segments were already answered, so there is nothing left to refuse. No comment at `:133` says so. + - Still opaque: what "settle" means here, compared with `IdleWaiters.settle`. + + 2. **`src/session.ts:239-323`, the shutdown verbs: `unbind`, `close`, `drain`, `finish`, `linkLost`, `dropLink`.** + - There are six near-synonyms, plus `LinkLife.stop`/`drop`/`end` underneath them. `stop()` gets called twice (in `drain` and again in `finish`). + - `unbind`'s return line, `sent.err && !closedOnUnbind ? … : drained`, needed a truth table. + - Re-entrancy: an inbound `unbind` (`incoming-requests.ts:149`) calls `session.close()`, which calls `incoming.drain()` on the same object that is still mid-`route`. + - Doc comments helped with each piece. The overall state machine was never written down in one place. + + 3. **`src/outgoing-requests.ts:120` `refusal()` and `:74` `request()`, with `src/link-life.ts:31` `LinkLife`.** + - `LinkLife` exposes seven predicates (`isAttached`, `isUp`, `isOver`, `isStopped`, `retrying`, `awaitsNextLink`, `refusal`). + - Callers mix them with `canCarry()` (which also checks `sock.destroyed`). `refusal()` at `:129` reads `isStopped() && canCarry()`, and its comment ("the link's own refusal names the session closed instead") only made sense once I had held all four phases in my head. + - Also surprising: the phase starts at `'up'` before any bind. I had to hunt through `comeBackUp` to see that `'binding'` exists only on reconnect. + + 4. **`src/reassembly.ts:186` `Reassembler.trim`, with `src/expiring-groups.ts:70` `ExpiringGroups.weigh`.** + - `ExpiringGroups` is a store whose rules its owners enforce: `set()` never evicts, `weigh()` does, `full` is only advisory, and `onSweep` must call `takeExpired()`. + - `trim` reweighs the whole group, may evict the group it is working on, and then computes `answered = parts.size - 1` for that one. + - The `:199` comment explains the `-1`. It took several rereads to see why `open()` evicts by count and `trim` by weight. + + 5. **`src/pdu.ts:84` `resolveShortMessage` and `:113` `resolveBody`.** + - `CodingSource` decides whether `short_message` or `message_payload` gets to set `data_coding`. An empty-string `short_message` flips it to `'message_payload'`. + - I had to hold four cases at once: Buffer vs string, empty vs not, plus the TLV text. The type comment at `:74` is accurate but dense. + - I had no domain context for why a body could live in two places. The README's "Where the body is" resolved that. + + 6. **`src/client.ts:253` `initialAttempts`, `:276` `keepTrying`, `:218` `bindOn`.** + - This is a second use of `ReconnectLoop`, separate from the one `Session` owns. It builds a fresh `Session` per attempt, and a `lastErr` closure is shared across attempts. + - `bindOn` comes with an ordering warning: "close() must reach the loop's stop() before its first await". Checking that required reading `Session.close` → `drain` → `reconnectLoop.stop()`. + - It resolved once I saw that `fromStart` is the only path into this code. + + 7. **`src/dlr-merger.ts:150` `open`, `:166` `spend`.** + - There are two `ExpiringGroups`, and the second one (`spent`) is a tombstone set. `spend` deletes from both, evicts the oldest tombstone, then re-adds. + - The class doc (`:62-67`) explains the "merged once" rule. Without it this would have stayed opaque. + + 8. **`src/defs/encodings.ts:162` `messageClassEncoding`, `:182` `encodingByDataCoding`, `:45` `ascii`.** + - Bitmask rules come from a spec I have not read. The codec named `ascii` is actually the GSM 03.38 table (`GSM: ascii`), which misled me at first. + - I took the comments on trust; I could not check them. + + I opened no tests. + +2. **The unit I would least want to modify:** `HandledMessages` together with `createSms` (`handled-messages.ts:92-140`, `sms.ts:83-157`). The "answered" state lives in three places: `answer.done` in the closure, `handled.answered`, and `answeredOnArrival`. They are updated by callbacks across two files. Whether the peer gets exactly one response depends on all three agreeing, and a mistake silently double-answers or never answers a request. + +3. **Expected hard, found easy:** + - The wire codec. `defs/types.ts` is long but repetitive, and every read and write checks its range the same way. + - `PduFramer` and `PduTransport`. + - `send-sms.ts`: `checkOptions` is a flat, ordered pipeline. + - `sms-id.ts`, `concat.ts`, `udh.ts`: small files whose names tell the truth. + - The "nothing throws" rule makes every call site look the same, so I stopped needing to think about control flow. + +4. **Prose debt.** + - **Needed:** + - I needed domain background: what a DLR is, `esm_class`, `data_coding`, UDH vs `sar_*`, and why `data_sm` changes meaning with direction. None of it is in `src/`. + - I found it in README.md sections "Receiving in depth", "Server in depth" and "Delivery receipts". That cost reading about 780 lines to extract about 60 useful ones. + - Comments cite SMPP section numbers (e.g. "5.3.2.26", "4.6.2") that a junior cannot resolve without the spec. + - The AGENTS.md architecture list was the most valuable single piece: one line per file, and accurate. + - **Told me nothing:** + - Comments that restate the code: `Session.send` "Sends a request and resolves with the peer's response.", `client()` "Connects to an SMSC and binds.", `LinkTimers.clear` context, and `bindCarries`'s doc, which mostly repeats its three lines. + - The long AGENTS.md "Conventions" paragraph on test fixtures (irrelevant to reading `src/`). + - The defects table: it is history, not an explanation of the current code, though it did hint at domain pitfalls. + +5. **Scores** (the problem's own difficulty is high: a stateful protocol with reconnect, drain and reassembly, and it gets no bonus here): + - **Navigation 7.** Predictable: the AGENTS.md file map plus descriptive file names got me to the right file first try for almost every question. What holds it below 8: one symptom such as "why was this refused with ESME_RTHROTTLED" is spread across `incoming-requests.ts` (`retryStatus`, `refusedSegmentStatus`), `handled-messages.ts` (`refuses`) and `reassembly.ts` (`Refusal`). + - **Locality 5.** Honest middle: liveness state in `LinkLife` is read through seven predicates from three classes. `generation()` is captured in closures (`incoming-requests.ts:106`, `:253`), `answered` is mutated through a callback, and ordering constraints are documented only in comments (`client.ts:239`, `session.ts:349`). + - **Shape 6.** Between 5 and 7: classes are small and fan-out is bounded, but some names mislead. The GSM codec is called `ascii`, and six-plus near-synonymous teardown verbs (`stop`/`end`/`drop`/`finish`/`linkLost`/`dropLink`/`clear`) mark distinctions I had to work out myself. + - **Self-sufficiency 5.** Honest middle: the code comments give terse, accurate reasons, but the domain model a newcomer needs to read them lives only in README.md and the SMPP spec, so I kept the README open the whole time. + - **Overall 5.** Capped at 6 by locality; I land at 5 because both locality and self-sufficiency cost me rereads on the stateful session core. The codec and message layers alone would sit near 7. + +SCORES nav=7 loc=5 shape=6 self=5 overall=5 + +## Draft D, mid seat + +1. **Hardest places, ranked hardest first** + + 1. **`src/session.ts:265-362`: `Session.drain` / `finish` / `linkLost` / `dropLink` / `comeBackUp`, read together with `src/link-life.ts:31-135` (`LinkLife`).** + - One question, "can a request go out right now?", depends on four pieces of state: `LinkLife.phase` (binding/down/ended/up), `LinkLife.stopped`, `ReconnectLoop.halted`, and `OutgoingRequests.canCarry()`. The last one is `link.isUp() && !sock.destroyed`. + - `drain()` calls `link.stop()`, then branches on `canCarry()`. `finish()` calls `stop()` again, then `dropLink()`, then `end()`. + - `comeBackUp` uses `!this.link.retrying()` to mean "close() landed during the rebind". Here `retrying()` is being used as a stand-in for "not stopped", which the name hides. + - I had to trace every caller by hand to be sure `close` fires exactly once and `disconnected` is never followed by `close` on the same drop. The per-method doc comments helped. Nothing ties the whole state machine together in one place; this stayed the most expensive read. + 2. **`src/outgoing-requests.ts:262-351`: `OutgoingRequests.request` / `refusal` / `attempt`.** + - A `for(;;)` loop holds one link-wait budget (`link.hold()` returns a closure) plus a window slot, and retries only when `retryOnNextLink && awaitsNextLink()`. + - `refusal` line 317 (`pastDrain !== true && isStopped() && canCarry()`) is a three-way condition. Its comment explains why the *other* branch exists, not this one. + - `attempt` registers `pending.wait` before `transport.write`, and checks abort twice (in `refusal` and again in `attempt`). The comment at line 325 resolved the second check. + - The `pastDrain` flag reaches here from `IncomingRequests` through `Session.incomingFor`. It is action at a distance. + 3. **`src/pdu.ts:84-136`: `resolveShortMessage` / `resolveBody` (plus `readParams` at 249).** + - The `CodingSource` return value decides whether an encoded `message_payload` may overwrite `data_coding`. Without the domain, I had to derive why an empty `short_message` hands `data_coding` to the TLV. The `CodingSource` doc comment half-resolves it. + - `readParams` passes `paramNumber(params.sm_length, 0)` as the length to *every* field's `read`. It only works because `sm_length` precedes `short_message` in wire order. That is an order dependence stated only as the general "parameter order is wire order" warning, not at the call. + 4. **`src/handled-messages.ts:359-423` and `src/expiring-groups.ts:442-554`: `HandledMessages.offer` / `run` / `refuses`, and `ExpiringGroups`.** + - `ExpiringGroups`'s contract is inverted: it "enforces neither max nor timeout itself", only `weigh()` evicts, `onSweep` must call `takeExpired()`, and `takeOldest` goes through `delete` (which stops the timer) while `takeExpired` goes through `remove`. Each owner (Reassembler, DlrMerger, HandledMessages) re-implements the policy. + - In `HandledMessages`, the `answered` flag is set by a closure threaded into `createSms`. `run` uses an identity check (`running.get(key) !== handled`) to detect that `clear`/`sweep` got there first. `refuses()` has hysteresis state (`atBound`) and calls `sweep()` as a side effect. + - The class doc comment resolved the intent. The mechanics took rereads. + 5. **`src/incoming-requests.ts:105-260` together with `src/server.ts:542-567`: `IncomingRequests.handle` / `route` / `onMessage` / `offer`, and `handleRequest`.** + - Searching for where a bind is accepted, I found `IncomingRequests.unhandled` answering binds with `ESME_RALYBND`. The real bind handling is in server.ts, injected as `onRequest`, so the "application hook" slot is also the server's own bind handler. + - The charter's Decisions index says this ("composes the application's onRequest after its own bind handling"), but the reader gets there only after a wrong turn. + - Link generation is checked twice by different mechanisms: inline in `handle`, and as a `lostLink` closure in `offer`. + - `carriedAs`/`standsInFor` rewrites `data_sm` depending on `linkEnd`, a mutable public field set after construction (`session.linkEnd = 'smsc'` in server.ts:586). + 6. **`src/reassembly.ts:425-444`: `Reassembler.trim`.** + - `weigh()` returns evicted groups, possibly including the current one. `answered = parts.size - 1` excludes the refused segment from the loss count. + - `collect` only weighs incomplete groups; the completing segment is never weighed. I had to confirm that is intended. + - The comments at 361 and 437 resolved it, after two reads. + 7. **`src/dlr.ts:342-424`: `messageType` / `receiptStatus` / `dlrFromPdu`.** + - Four message types, with `'unmarked'` meaning "maybe a receipt if the body parses to both an id and a state". Status comes from TLV, then body, then UNKNOWN, and `statusId` and `statusMsg` can disagree by design. + - This is domain-heavy but well commented with spec sections. README's "Delivery receipts" section closed the gap. + 8. **`src/defs/encodings.ts:302-341`: `messageClassOf` / `messageClassEncoding` / `encodingByDataCoding`.** + - Bit-twiddling over GSM 03.38 coding groups that I had no context for. The comments give the bit positions, so it resolves with care. + - The GSM codec object is named `ascii` (line 196), which is a lying name for GSM 03.38. + +2. **The unit I would least want to modify: the Session link lifecycle (`Session.drain` / `finish` / `linkLost` / `comeBackUp`) together with `LinkLife`.** + - Correctness depends on call order across three classes. `stop()` must reach the loop before the first await; client.ts:239 says so from *another file*. + - `close` and `disconnected` must stay exclusive, and `end()` must release waiters exactly once. + - Any change risks a hung `close()` or a double `close` event, and nothing local tells you which invariant you just broke. + +3. **Expected hard, found easy.** + - The wire codec in `defs/types.ts`: repetitive, every read is range-checked, and `Result` is uniform. + - `PduFramer`: short, with the quadratic concern stated. + - The `send-sms.ts` check pipeline: linear and flat, each refusal named. + - `DlrMerger` severity ranking: one comment explains why wire values can't be compared. + - `bind-direction.ts`: `standsInFor`/`bindCarries` are tiny and explained. + - The UDH walk in `udh.ts`. + - The no-throw discipline made every call site read the same way. + +4. **Prose debt.** + - **Needed, and where I found it:** + - What ESME and SMSC are. Only inferable from `LinkEnd`'s comment and README. + - What "answered on arrival" means. README "Server in depth", roughly a 400-line scroll. + - Why `data_sm` flips direction. The comment in bind-direction.ts is sufficient. + - How the server's bind handling composes with `onRequest`. Only in the AGENTS.md Decisions index, as one line whose reasoning is in docs/decisions.md, which I was barred from. + - The 134/153 segment budget. Covered both inline (message.ts:364) and in AGENTS, so it was cheap. + - Many AGENTS decision lines are pointers into a file I couldn't open. For the lifecycle ("A deliberate shutdown drains; an unusable link and an abort do not"), the one-liner was the only statement of the rule the code implements. + - The AGENTS architecture map was accurate and was the cheapest, most useful prose. + - **Told me nothing the code didn't already say:** + - `Session.send`'s "Sends a request and resolves with the peer's response." + - README's "Everything exported" table, which duplicates `index.ts`. + - The `defaults.ts` preamble. + - AGENTS "Conventions", about 30 lines on test fixtures and teardown, which are irrelevant to reading `src/`. + - The repeated "Injected so expiry can be exercised without a wall clock" on four options types. + - **Minor drift:** README types `onSms` as `(sms) => Promise | void`; the code declares `(sms) => unknown`. + +5. **Scores.** Intrinsic difficulty is high: a stateful protocol session with reconnect, drain, windowing and reassembly. It gets no bonus. + - **Navigation 8.** Above 7 "the layout answers where does this live": `src/` is flat, file names match contents, and the AGENTS map is accurate. It stops short of 9 because server bind handling lives behind the `onRequest` slot, which cost one wrong turn. + - **Locality 6.** Between 5 and 7: most units stand alone. Link liveness is split across `LinkLife.phase`, `stopped`, `ReconnectLoop.halted`, `canCarry()`'s socket check and generation counters. Changing shutdown means holding session.ts, link-life.ts, outgoing-requests.ts and client.ts at once, and `linkEnd` is mutated after construction. + - **Shape 7.** At "predictable": classes are small and fan-out is bounded per level. A few names lie: `ascii` for the GSM codec, `HandledMessages` for messages still being handled, `retrying()` used as "not closed", and `settle` meaning different things in five classes. + - **Self-sufficiency 7.** At 7: comments carry the why with spec section references at the hard points (receipts, UDH, data_coding bits). What is missing is the lifecycle invariant and the bind composition, which exist only as index lines pointing at a decisions file. + - **Overall 6.** Capped at loc+1 = 7. I place it at 6 because the hardest part, the session lifecycle, is hard both because the problem is hard and because its state is spread across files. It is neither localized nor marked as one place. + +SCORES nav=8 loc=6 shape=7 self=7 overall=6 + +## Draft D, senior seat + +1. **Hardest places, hardest first** + + 1. **The link lifecycle across four owners.** `src/session.ts:265-362` (`drain`, `finish`, `linkLost`, `dropLink`, `comeBackUp`), `src/link-life.ts:31` (`LinkLife`), `src/reconnect-loop.ts` (`stop`/`halted`) and `src/outgoing-requests.ts:120` (`refusal`, which uses `canCarry()` = `link.isUp() && !sock.destroyed`). + - There are three "stopped" notions: `LinkLife.stopped`, `ReconnectLoop.halted`, and `phase === 'ended'`, which also sets `stopped`. `drain()` and `finish()` each call `link.stop()` and `reconnectLoop?.stop()`, so I had to hold the order of the calls to see which one decides the `close` event versus `disconnected`. + - `link-life.ts:38` starts `phase` at `'up'`, although `'binding'` exists. The first link therefore reports `isUp()` before any bind, and the charter's "a bind is what makes it one" holds only for reconnected links. I worked this out myself; nothing in the code says so. The code mostly explains itself, but that one point stayed opaque. + 2. **Who answers a failed handler.** `src/handled-messages.ts:92-140` (`offer`, `run`), together with `src/sms.ts:83-157` (`createSms`, `sendResp`, `answeredOnArrival`) and `src/incoming-requests.ts:252` (`offer`). + - An `answered` flag is set through a callback that `offer` builds around a `handled` const, which that same closure refers to. `SmsRoute = Omit` and a mutable `Answer` object add further state, and the `lostLink` generation closure is built in yet another class. + - When a multipart handler fails, `run()` calls `sendResp({status: retryStatus})`. That returns an `err` from `answeredOnArrival`, and the `err` is discarded. That is how "its answer stands" comes out right, and nothing says so. README's "A handler that fails" bullet resolved it. + 3. **Reassembly eviction.** `src/reassembly.ts:110` (`collect`) and `:187` (`trim`), with `src/expiring-groups.ts:70` (`weigh`). + - `weigh()` can evict the group that is being weighed, so `trim` counts it as `parts.size - 1` answered. The `full` / `unplaceable` refusals map to three statuses in `refusedSegmentStatus`. + - `ExpiringGroups` enforces its limits unevenly: only `weigh` evicts, while `max` and `timeout` fall to the owners, and I had to find that in its class docstring. The inline comments resolved it, but it took a reread. + 4. **Building and reading the message body.** `src/pdu.ts:84-136` (`resolveShortMessage`, `resolveBody`) and `:249` (`readParams`). + - On the write side, `CodingSource` decides which of `short_message` and `message_payload` may overwrite `data_coding`. An empty buffer counts as `'message_payload'`. I had to hold four branches at once. + - On the read side, `readParams` passes `sm_length` as the `length` argument to every wire type's `read`. The same parameter means the TLV length in `defs/types.ts`. Only the `commands.ts` comment on wire order hints at this coupling. + 5. **data_coding bit logic.** `src/defs/encodings.ts:159-190` (`messageClassEncoding`, `encodingByDataCoding`). + - It is dense bit-twiddling with no context, and a `//` comment sits above a separate `/** */` block, so I could not tell which of the two it belonged to. + - Some names are wrong. `'LATIN1'` is returned for 8-bit binary. The GSM codec is named `ascii` (`:45`). + - The docblocks resolved most of it. The class-group comment stayed only half clear. + 6. **`DlrMerger`'s two stores.** `src/dlr-merger.ts:68`, with `spend` at `:166` and `open` at `:150`. Two `ExpiringGroups` (`groups` and `spent`) are both mutated by `spend()`. `open()` checks both, and `dropOldest` spends. The class docstring explained the "merged at most once" rule. I still had to trace the steps by hand. + 7. **Retrying the first bind.** `src/client.ts:218-320` (`bindOn`, `initialAttempts`, `keepTrying`). This is a second `ReconnectLoop` outside `Session`, with a fresh session per attempt and a `lastErr` closure. Correctness depends on the order in which abort listeners are added and removed, and on the comment "close() must reach the loop's stop() before its first await". The comments resolved it. + +2. **The unit I would least want to modify:** `LinkLife` (`src/link-life.ts`). `Session`, `OutgoingRequests` (`isUp`, `awaitsNextLink`, `refusal`, `hold`) and `IncomingRequests` (`generation`) all read its phase. Its `stopped` flag duplicates the reconnect loop's, and its initial `'up'` is an unstated exception to its own `binding` rule. A change there reaches the drain, the queued sends and response correlation, and no single file shows all of that. + +3. **Expected to be hard, found easy:** + - The codec: `defs/types.ts` is long but uniform, and every read is range-checked the same way. + - `PduFramer`, `ReconnectLoop` and `PendingRequests`. + - The typing of `TlvInputs` and `Tlvs`. + - `splitMessage` and the budget per segment. + - Navigation overall: the charter's one-line-per-file map matched the tree exactly. + +4. **Prose debt** + - **Needed, and what it cost to find:** + - README "Receiving in depth" and "Shutdown", to learn the half-bound hysteresis, the five-minute handler cutoff, and what happens when a handler fails after answering. Finding them was cheap, but they sit in a user document, not beside `HandledMessages`. + - The rationale for the link-life decisions. AGENTS.md only indexes it ("One owner decides whether a link can carry a request…") and I was not allowed to open `docs/decisions.md`, so the initial-`'up'` question stayed open. + - I opened no tests. + - **Told me nothing the code did not already say:** + - `session.ts:203` "Sends a request and resolves with the peer's response". + - The getter docstrings on `boundAs` and `peerInterfaceVersion`. + - `UnansweredError`'s docstring, which restates its message. + - `retryStatus`'s docstring. + - The idle-timeout rationale, written twice (`defaults.ts:13` and `client.ts:193`). + - `checkSessionOptions`'s docstring describes one case, `maxOutstanding: 0`, not the function, which misleads slightly. + - AGENTS.md's 0.4.0 defect table and its long test-fixture paragraph cost reading time and did not help with `src/`. + - **Small duplication noticed:** `collectSent` in `send-sms.ts` and `collectReceipt` in `sms.ts`, and `quoted()` in both `session-options.ts` and `bind-direction.ts`. + +5. **Scores** + + | Dimension | Score | Anchor and cause | + | --- | --- | --- | + | Navigation | 8 | Between 7 and 9. The Architecture map and truthful file names took me from symptom to file first try every time. The lifecycle behaviour spread over `Session`, `LinkLife` and `ReconnectLoop` is what keeps it from 9. | + | Locality | 6 | Between 5 and 7. Most collaborators are standalone, with injected `now` and dependencies. But whether a link can carry a request, is stopped, or has ended is split across `LinkLife`, `ReconnectLoop`, `OutgoingRequests.canCarry` and order-dependent calls in `Session`, and the handled-message `answered` state runs through closures in three files. | + | Shape | 7 | Predictable. Fan-out is bounded per level and nearly every name tells the truth. The exceptions are `ascii` for GSM, `LATIN1` standing for binary, and `isUp()` being true before the first bind. | + | Self-sufficiency | 7 | Predictable. Inline comments carry most of the "why" (SMPP section references, peer quirks). The handler bound and failure semantics needed README, and the reasoning behind the link-life decisions sits in a document I could not open. | + | Overall | 7 | Predictable, within the cap of lowest dimension plus one. The hard corners are few and I know which to fear, but the lifecycle corner is spread across four files instead of sitting in one marked place. | + + The problem is intrinsically hard: SMPP session semantics, reconnecting with no resends, a draining shutdown, and reassembly under memory bounds. The scores give no bonus for that. + +SCORES nav=8 loc=6 shape=7 self=7 overall=7 + +## Draft D, architect seat + +**Comprehension panel report: Architect, inherited (draft-d)** + +**Order note:** I read AGENTS.md right after README and the tree, before I had written the map down. Its architecture listing matched the map below and changed nothing in it. I opened no test files. + +### 1. Map (README + tree only, verbatim) + +Top-level areas I expected in `src/`: +- **A. Entry points:** `index.ts` for the public surface, `client.ts` for connect and bind with reconnect, `server.ts` for the listener, auth and close. +- **B. Session core:** `session.ts` as the hub. `session-options.ts` and `defaults.ts` for options. `bind-direction.ts` for which commands a bind type carries. +- **C. Link lifecycle:** `link-life.ts` (up, down or ended?), `link-timers.ts` (enquire_link and idle), `reconnect-loop.ts` (backoff), `pdu-transport.ts` (socket to PDUs). +- **D. Outbound:** `send-sms.ts` (split and submit), `outgoing-requests.ts` (the request path), `pending-requests.ts` (seqNr correlation), `send-window.ts` (maxOutstanding), `unanswered-error.ts`. +- **E. Inbound:** `incoming-requests.ts` (dispatch), `sms.ts` (the onSms handle), `handled-messages.ts` (probably the "held while the handler runs" bound), `reassembly.ts`, `concat.ts`, `udh.ts`, `message-body.ts`. +- **F. Receipts:** `dlr.ts` (parse), `dlr-merger.ts` (messageDlr), `sms-id.ts` (hex/decimal and `-`). +- **G. Codec:** `pdu.ts`, `pdu-framer.ts`, `pdu-refusal.ts` (PduRefusedError), `retained-pdu.ts` (?), `defs/*` (spec tables). +- **H. Text:** `message.ts` (encode, split, smppTime) and `defs/encodings.ts`. +- **I. Utilities:** `result.ts`, `error-from.ts`, `log.ts`, `uuid.ts`, `idle-waiters.ts` (?), `expiring-groups.ts` (?). + +Names that did not give their purpose: +- `idle-waiters`: idle peer or idle count? +- `retained-pdu` +- `expiring-groups`: groups of what? +- `handled-messages`: reads as "already handled". +- `error-from` +- `defaults` vs `session-options` + +README features I could not place, or that were missing: +- Goal 9's store is absent, as the README says. +- smppTime and smppDate: presumably `message.ts`. +- At the repo root, `MIGRATION-NOTES.md` and `DESIGN.md` beside `MIGRATION.md` and `docs/decisions.md`: I cannot tell their purpose apart from the others. + +**Where the map was wrong, and what each correction cost:** +- **`handled-messages.ts` (medium cost, and it is the crux of the 3am question).** I guessed "held until `sendResp()`". It actually holds a message until the **handler's promise settles**. `answered` is recorded only to decide the retry refusal. +- **`link-life.ts` (medium).** It is not only a state flag. It is also the queue where requests wait for the next link, with two orthogonal state variables, `phase` and `stopped`. +- **`expiring-groups.ts` (medium).** It is a shared TTL store whose contract is "enforces neither max nor timeout itself" (`expiring-groups.ts:18`). Each of its three owners re-implements the policy differently. `HandledMessages` uses it for running handlers, which are not groups. `DlrMerger` uses a second instance as a "spent" set. +- **Cheap corrections:** + - `session-options.ts` also holds `SessionEvents` and all option validation. + - `bind-direction.ts` also holds bind-record validation (`checkedBind`) and `undeclaredInterfaceVersion`. + - `reassembly.ts` holds `decodeSegments`, which `sms.ts` uses. + - `idle-waiters.ts` is "wait until a count reaches 0". + - `retained-pdu.ts` copies PDUs off the wire and weighs them. + +### 2. Fan-out by level +- **L0, repo root:** 22 entries, 8 of them prose documents. Two documents I could not tell apart by name. +- **L1, `src/`: 36 files plus `defs/`, all flat. This is the worst level.** Only names and the AGENTS listing group them into the 9 areas above. Nine is bounded; 37 is not, and the directory gives no help. +- **L2, `defs/`:** 7 files, bounded and clear. +- **L3, units:** + - `Session` wires 9 collaborators and has about 15 methods. + - `IncomingRequests` owns 3 things and reaches back into `Session`. + - `OutgoingRequests` owns 3. + - `LinkLife` has 12 methods over 2 state variables. By method count it is the densest unit. + +### 3. Names +**One name over several concepts:** +- **idle** means four things: + - `IdleWaiters`: a count falls to 0. + - The `LinkTimers` idle timeout: a silent peer. + - `ExpiringGroups.idle()`: stop the sweeper. + - `SendWindow.idle()`: the drain. +- **refusal** means four things: + - `pdu-refusal`: an unreadable PDU. + - `LinkLife.refusal()`: the session is over. + - `OutgoingRequests.refusal()`: a request cannot go out. + - The reassembly `Refusal`: `'full' | 'unplaceable'`. +- **settle** covers waiters, pending requests, `IdleWaiters.settle` and `HandledMessages.settle()`, which means "wake the drain if empty". +- **Shutdown verbs:** `stop`, `end`, `finish`, `drop`, `dropLink`, `linkLost`, `halted`, `isOver`, `isStopped`. `link.stop()` refuses new work while `reconnectLoop.stop()` halts timers: same verb, different meanings. + +**One concept with several names:** +- "Answered": `Answer.done` (`sms.ts:81`), `Handled.answered` (`handled-messages.ts`), `answeredAs` and `answeredOnArrival`. +- Writing a response: `answer()`, `sendReturn()`, `sendResp()`. +- The reassembly octet cap: option `maxOctets` vs `defaults.maxReassemblyOctets`. The option name does not say "reassembly", yet a sibling cap exists (`maxHandledOctets`). +- The server's idle timeout: the literal `defaults.idleTimeout` 40 000 vs the client's derived `2 × enquireLinkInterval`. +- Two date formatters, `smppDate` and `smppTime.encode`, in `message.ts`, with duplicated pad chains. + +**Misleading:** +- `HandledMessages` means "being handled". The README calls them "messages being handled". +- `ExpiringGroups` holds running handlers, which are not groups. +- `bind-direction.ts` holds more than direction. + +**Copies:** +- `quoted()` appears twice (`session-options.ts:78`, `bind-direction.ts:54`). +- `collectSent` (`send-sms.ts:274`) and `collectReceipt` (`sms.ts:183`) are near-twins. +- An inline `thrown instanceof Error ? … : new Error(String(thrown))` appears three times (`client.ts:89`, `reconnect-loop.ts:80`, `reconnect-loop.ts:136`) instead of `errorFrom()`. + +### 4. What I would restructure, ranked +1. **Group `src/` into about 6 folders:** link, outbound, inbound, receipts, codec, text. The AGENTS listing already draws those lines, so this only moves the map from a document into the layout. +2. **Give the shutdown/link vocabulary one owner.** + - Collapse `LinkLife.phase` and `stopped` into one state enum. + - Rename so that "stop" means one thing everywhere. + - Move `OutgoingRequests.refusal`'s `pastDrain && isStopped && canCarry` condition (`outgoing-requests.ts:129`) behind one `LinkLife` predicate. +3. **Make `ExpiringGroups` enforce its own policy,** or split it into a TTL store and a set. Today three owners re-implement "full", weight and sweep, and its sweeper interval equals its timeout. So expiry is lazy by up to 2× (`expiring-groups.ts:60`): the README's "five minutes" handler cap is really 5–10 minutes when no traffic arrives. That is a plausible claim drift; I derived it from the code and have not verified it. +4. **Merge the "answered" state into one place,** so `sms.ts` and `HandledMessages` stop tracking the same fact. +5. **Use `errorFrom()` everywhere.** + - `reconnect-loop.ts:80` runs `String(thrown)` inside the `.catch` that is meant to contain an application throw. `errorFrom`'s own comment says `String()` can throw. + - If it does, `void this.run()` rejects unhandled and `attempting` stays `true`, which wedges the loop. The trigger is a null-prototype object thrown from an application-supplied `ReconnectOptions.connect` or `onConnected`, reachable because `Session` is publicly constructible. + - I call this plausible, not verified. + +**What the structure gets right:** +- Files are small (all under 420 lines). +- Every file name maps to one noun that also appears in `Session`'s fields. +- `Session` reads as a table of contents. +- Imports point one way. +- Hard-rule-1 result types are uniform. +- Comments carry the WHY at the line: spec section numbers, peer quirks, past defects. +- `defs/` is clean. +- `PduFramer`, `PendingRequests`, `SendWindow` and `ReconnectLoop` each fit in the head alone. + +### 5. The 3am question +**Symptom:** during a graceful shutdown the session hangs until `shutdownTimeout`, although the application called `sendResp()` on every message. + +**Path, cold, about 2–3 minutes:** `Session.close` → `drain` (`session.ts:265`) → `this.incoming.drain(...)` (`session.ts:274`) → `IncomingRequests.drain` (`incoming-requests.ts:163`) → `HandledMessages.idle` (`handled-messages.ts:111`). + +**The unit is `HandledMessages.run` (`handled-messages.ts:125`).** The entry is deleted at `:138` only after `await this.handle()` returns. `sendResp()` only flips `handled.answered`, and the drain never reads it. + +**So the peer's handler has not returned.** The likely cause is that it is awaiting `sms.sendDlr()`. That call waits for every `deliver_sm_resp`, up to `responseTimeout`, which defaults to 30 s, longer than the 5 s shutdown. It can also wait without bound behind a full send window, because `sendDlr` passes no signal. + +This is designed behaviour: the `OnSms` type doc, README lines 108 and 389, and the AGENTS decision all say it. The fix is on the caller's side (return the handler, or fire-and-forget the receipt). The code states this at the type (`session-options.ts:39`), so no document is needed. + +**Where it rots first:** +- The link/shutdown triangle: `Session.drain`/`finish`/`linkLost`/`dropLink`/`comeBackUp` plus `LinkLife` plus `OutgoingRequests.refusal`. Three units read `LinkLife` state. Correctness depends on call order: `link.stop()` before `canCarry()`, `drop()` before `end()`. `IncomingRequests` also snapshots `link.generation()`. +- Next, `ExpiringGroups` and its three divergent owners. + +**Where the next two features land:** +- **Goal 9's store** would have to thread an interface through `Session` → `IncomingRequests` → `Reassembler`/`HandledMessages`/`DlrMerger`, which is every `ExpiringGroups` owner. That is the costliest seam in the code base. +- **A per-PDU rate-limit hook (goal 7)** lands cleanly in `OutgoingRequests.request` beside `SendWindow.acquire`. + +### 6. Hardest places, ranked +1. `session.ts:265-362`: `drain`, `finish`, `linkLost`, `dropLink`, `comeBackUp`. Order-dependent shutdown across 4 collaborators. +2. `link-life.ts:31` `LinkLife` as a whole: `phase` × `stopped`, and 7 predicates with overlapping meanings. +3. `outgoing-requests.ts:74-134`, `request` and `refusal`: the link-wait/window retry loop and the `pastDrain` exemption. +4. `handled-messages.ts:67-138`, `refuses`/`offer`/`run`: hysteresis, a hidden sweeper timer, and the answered flag written from `sms.ts`. +5. `expiring-groups.ts` `weigh` together with `reassembly.ts:187` `trim`: eviction can take the current key. +6. `pdu.ts:84-136`, `resolveShortMessage` and `resolveBody`: the `CodingSource` rules. +7. `incoming-requests.ts:218-260`, `onMessage` and `offer`: the bound check, reassembly, answer on arrival, and generation capture. +8. `dlr.ts:216` `dlrFromPdu`: the `messageType`/`receiptStatus` precedence. + +**The unit I would least want to modify is `LinkLife`.** Everything that decides whether a request may go out reads it, and its meaning is spread over 7 predicates. + +**Intrinsic difficulty:** high. It is an SMPP session layer with reconnect, a send window, reassembly, receipt merging and drain semantics, and it earns no bonus for that. + +### 7. Scores +- **Navigation 7:** "Predictable". The file names plus `Session`'s field list got me from symptom to unit in 3 hops. The flat 37-file `src/` and the misleading name `HandledMessages` keep it below 9. +- **Locality 6:** between "honest middle" and "predictable". `LinkLife` state is read by `Session`, `OutgoingRequests` and `IncomingRequests`, and shutdown correctness depends on call order. `ExpiringGroups` pushes its own policy onto three owners. +- **Shape 6:** between "honest middle" and "predictable". Units are small and mostly named truthfully. But L1 has 37 ungrouped entries, and "idle", "refusal", "settle" and "stop" are each overloaded. +- **Self-sufficiency 7:** "Predictable". Comments at each unit state its invariants and the spec section behind them, and the 3am answer is readable at the `OnSms` type with no document open. A reader still needs the AGENTS listing to see the area grouping that the layout does not show. +- **Overall 6:** capped by locality and shape at 6. A cold senior is productive within a week on everything except the link/shutdown triangle. + +SCORES nav=7 loc=6 shape=6 self=7 overall=6 diff --git a/docs/comprehension-rewrite/panels/round-3-drafts-e-f.md b/docs/comprehension-rewrite/panels/round-3-drafts-e-f.md new file mode 100644 index 0000000..ca0a759 --- /dev/null +++ b/docs/comprehension-rewrite/panels/round-3-drafts-e-f.md @@ -0,0 +1,710 @@ +# Round 3: drafts E and F + +## Draft E, junior seat + +1. **Hardest places, hardest first** + + 1. **`src/session-life.ts:175` `SessionLife.attempt()`, with `enter()` at `:115` and `linkLost()` at `:109`.** The async reconnect continuation re-enters the state machine after two awaits. It snapshots `this.links` and later compares it, and it reads state through `is()` only to stop TypeScript narrowing. The step to `bound` is not in this file: it goes `rebind` → `client.ts` `bind()` → `Session.bound()` → `life.bound()`, three files away. There is also hidden re-entrancy. `effects.linkDown()` calls `sock.destroy()`, which fires the transport's `onClose` → `life.linkLost()`. That call is harmless only because `attached()` is already false. The ASCII diagram at `:8-24` and the comment "a transition from any other state is ignored" resolved most of it. Why `links` could change during `rebind` stayed opaque. + 2. **`src/outgoing-requests.ts:97` `request()`, `sendOnce()` at `:116` and `attempt()` at `:162`.** There is a `for(;;)` retry around three nested waits: link, window slot, response. Each has its own deadline or timeout semantics, and `written` plus `state() !== 'down'` decide whether to loop. The `misuse()` check runs here and again in `Session.send()` (`session.ts:180`), and the reason for the duplicate is a riddle comment ("named as one ahead of the drain"). The JSDoc on `request()` and the `UnansweredError` naming resolved the intent. I had to read `README` "Sends and the link" to trust it. + 3. **`src/handled-messages.ts:61` `refuses()` and `:120` `run()`.** `refuses()` looks like a predicate but sweeps, logs, and flips `atBound` hysteresis. `run()` answers the peer after the handler, and the answer depends on whether `sms.answered` was flipped by a closure inside `sms.ts`. It then removes the entry only if `running.get(key) === sms`, because a sweep may already have dropped it while the handler keeps running. `ExpiringGroups` gets `max` here but, by its own doc, does not enforce it, so I had to go and read `expiring-groups.ts` to know who does. + 4. **`src/reassembly.ts:186` `trim()`, with `ExpiringGroups.weigh()` at `src/expiring-groups.ts:70`.** `weigh()` evicts the oldest groups and may return the current key itself. Then `answered = parts.size - 1` subtracts the just-arrived segment, because that one gets a `full` refusal and the peer keeps it. Holding "set never evicts, weigh does, owners check full" across two files cost two rereads. The inline comments resolved it. + 5. **`src/sms.ts:82` `createSms()` and `:126` `sendResp()`.** A mutable `answer` record is captured in a closure and exposed through getters. `link` is the socket at arrival, compared by `destroyed` rather than against `session.sock`. `answeredAs` means "multipart, already answered on arrival". I only understood why `sendResp()` on a multipart message is a no-op after reading README "Server in depth" (answered on arrival). The code alone did not tell me. + 6. **`src/pdu.ts:84` `resolveShortMessage()` and `:113` `resolveBody()`.** The `CodingSource` idea is hard for someone without SMPP: which of `short_message` and `message_payload` gets to set `data_coding`, and when an empty buffer counts as "payload". Also, `readOptionalParams()` at `:267` retries parsing with one skipped NULL. The comments are accurate but assume the domain. README "Building" and the SMPP terms table were needed. + 7. **`src/defs/encodings.ts:152-191` `messageClassOf()`, `messageClassEncoding()`, `encodingByDataCoding()`.** Bit masks (`0x80`, `0x10`, `0xF0`, `>> 2 & 0x03`) against GSM 03.38 coding groups I have never seen. The comments cite spec sections I cannot open. This stayed opaque. I trust it only because the tests presumably pin it; I did not open them. + 8. **`src/client.ts:240` `retryUntilBound()` and `:278` `keepTrying()`.** This is a second backoff loop, separate from `SessionLife`'s. Its `settle` callback is called on every failure and does not settle anything; it only records `lastErr`. The name misled me until I read the callback body at `:290`. AGENTS' architecture line ("the first-connect retry of reconnect.fromStart") told me why it exists. + +2. **The unit I would least want to modify:** `SessionLife.enter()` / `attempt()` (`src/session-life.ts:115-203`). Every lifecycle effect fans out from it through `LifeEffects` closures defined in `session.ts:260`. Those closures call back into the transport, whose socket events call `life.linkLost()` again. Correctness rests on "ignored from any other state" and on the ordering of effects before emit. I could not predict what a new transition would re-trigger without running it. + +3. **Expected hard, found easy:** + - `PduFramer`: small, one job, and the quadratic-avoidance comment explains the only trick. + - The wire-type table in `defs/commands.ts`, where the wire-order warning sits right at the table. + - The result pattern and "nothing throws": consistent everywhere, so no surprises. + - `defaults.ts`: one place, grouped. + - `sms-id.ts`, `concat.ts`, `message-body.ts`, `log.ts`, `pdu-refusal.ts`: each read cold in one pass. + - `send-sms.ts`: long but linear, a chain of `check*` functions. + - The AGENTS architecture map, which got me to the right file first time for every question I had. + +4. **Prose debt** + - **Documentation I needed:** + - README "SMPP terms" table: essential and cheap to find, linked from the table of contents. + - README "Server in depth" and "Receiving in depth", for answered-on-arrival and the handled-message bound. The rules behind `sms.ts` and `handled-messages.ts` live there, not in the code. + - AGENTS architecture list: essential for navigation. + - The AGENTS decisions index lines are cryptic without `docs/decisions.md`, which I was not allowed to open (for example "A report is final unless its `esm_class` or its state says otherwise"). + - Spec knowledge (`esm_class` bits, `data_coding` groups) is cited by section number and never explained. That cost the most and was never repaid. + - **Prose that told me nothing the code did not already say:** + - The duplicated deliver JSDoc in `outgoing-requests.ts:84` and `pending-requests.ts:57`. + - `/** A socket is on the link. */` on `attached()`. + - The AGENTS "Defects found in 0.4.0" table, which is history and not a guide to the current code. + - The AGENTS test conventions, which are irrelevant to reading `src/`. + - A different cost: many comments are compressed to riddles and needed several reads each. Examples are "Announced when the wait is over rather than when it starts: a cancelled one never happened." (`session-life.ts:160`) and "A misuse is named as one ahead of the drain, rather than blamed on the shutdown." + +5. **Scores** + - **Navigation 7:** at the "Predictable" anchor. The AGENTS file map and honest file names (`pdu-framer`, `link-timers`, `sms-id`) took me symptom → file first try. It stops short of 9 because reconnect lives in two places (`session-life.ts` and the separate `client.ts` retry loop). + - **Locality 5:** at the "Honest middle" anchor. Collaborators are wired by closures back into `Session` (`LifeEffects`, `PduTransport` callbacks, `IncomingRequests` calling `session.emit`, `.sock` and `.close`). The `Sms` answer record is mutated from two modules. Safe changes in `SessionLife` and `HandledMessages` need the whole session held in your head. + - **Shape 6:** between 5 and 7. Fan-out per level is bounded and the files are small. Names overload or lie, though: + - `settle` means four different things (`IdleWaiters`, `PendingRequests`, `SendWindow`, the `client.ts` callback that settles nothing). + - `handlers` in `IncomingRequests` means `{answer, send}`, not `onSms`. + - `refuses()` and `closing()` read as predicates but mutate. + - `SessionLife.carries()` is dead: unused in `src/`, duplicated by the switch in `OutgoingRequests.waitForLink()`. + - **Self-sufficiency 5:** at the "Honest middle" anchor. The generic parts (framer, codec types, results, timers) stand alone. The session semantics (answered-on-arrival, the handled-message bound) and all the `data_coding`/`esm_class` bit logic needed README sections or the spec open beside the code. + - **Overall 5:** as someone new to the domain, I would take an area in a day or two, with rereads and some wrong turns. The problem's intrinsic difficulty is genuinely high (a protocol state machine, reassembly, and receipt correlation over a flaky link), and it gets no bonus here. + +SCORES nav=7 loc=5 shape=6 self=5 overall=5 + +## Draft E, mid seat + +1. **Hardest places, hardest first** + + 1. **The answer to an inbound message**, `incoming-requests.ts:218` (`IncomingRequests.onMessage`), `handled-messages.ts:120` (`HandledMessages.run`) and `sms.ts:126` (`sendResp`), with `sms.ts:82` (`createSms`) holding the `Answer` record. + - Whether the peer has been answered, and under which id, is decided in three places: + - `onMessage` answers multipart segments one by one on arrival and passes `answeredAs`. + - `createSms` turns `answeredAs` into a pre-set `status: 'ESME_ROK'`. + - `run` answers after the handler only when `!sms.answered`, choosing the retry status if the handler failed. + - "Which link" is carried as a `Socket` identity that is compared through `.destroyed` in two places (`IncomingRequests.handle` and `sendResp`). + - To reason about "handler throws after a multipart message" I had to keep all three files in my head. The `Sms` type docs and the README section "Server in depth" resolved it. It is followable but not local. + 2. **`ExpiringGroups` and the classes built on it**, `expiring-groups.ts:19`, with `reassembly.ts:187` (`Reassembler.trim`) and `dlr-merger.ts:105/150/166` (`collect`, `open`, `spend`). + - The store refuses to enforce its own `max` and `timeout` ("owners check full and call takeExpired(); only weigh() evicts"). So each of the three owners re-implements the capping itself (`open` → `dropOldest`, `sweep` before `collect`). + - `weigh()` can evict the very key being weighed. `trim` then does `answered = parts.size - 1` for that case, and I needed two reads to see why. + - `DlrMerger` runs a second `ExpiringGroups` (`spent`) as a tombstone set, with `spend()` writing to both stores. + - The doc comments stop you misusing the store, but understanding any one owner means holding the store's partial contract. + 3. **`resolveShortMessage` / `resolveBody`**, `pdu.ts:84` and `pdu.ts:113`. + - `CodingSource` decides whether `short_message` or `message_payload` may set `data_coding`. That depends on Buffer vs string vs empty, and on whether the command's table has a `short_message` at all. + - I had to hold four or five branches at once, and `data_coding` gets rewritten in two different spots. + - The type comment on `CodingSource` helped. The rule behind it ("a string body is written in the alphabet its own data_coding names") is only a title in the AGENTS index. + 4. **The retry loop in `OutgoingRequests.request` / `sendOnce`**, `outgoing-requests.ts:97` and `:116`. + - `sendOnce` returns `undefined` to mean "loop again". Whether to loop depends on `attempt.written` and on a live read of `state() !== 'down'` after two awaits. + - With `LinkWaiters`, `SendWindow` and `PendingRequests` underneath, a send passes through four waits with three different abort and timeout rules. + - The doc comments on `request` and `Attempt.written` resolved it, as did the README bullets under "Sends and the link". + 5. **`SessionLife.enter` / `attempt`**, `session-life.ts:115` and `:175`. + - The ASCII state diagram is very good. What cost me was `attempt()`: it re-checks `is('down')` after `connect`, then `this.links !== link || !is('connected')` after `rebind`. + - `rebind` calls `Session.bound()`, which calls `life.bound()` → `enter('bound')`. That is a re-entrant path back into the machine from inside an await. + - The comment "the one continuation that re-enters the machine" marks it, and the diagram resolved it. + 6. **`Session.unbind` / `drain`**, `session.ts:218` and `:317`. + - `life.closing()` is a query-named method that performs the transition. + - `closedOnUnbind = wasOpen && !this.life.attached()` infers that the peer dropped the link in answer to our unbind. + - The return value orders two errors with different priorities. + - The doc comment helped. The mutating `closing()` still surprised me. + 7. **The two reconnect loops**, `client.ts:240` (`retryUntilBound`) and `client.ts:278` (`keepTrying`). + - AGENTS says "the reconnect loop is the `down` state", but `fromStart` is a second, hand-rolled backoff loop in `client.ts`. The charter's file list does mention it. + - The callback named `settle` is called on every failed attempt and does not settle: it only records `lastErr`. That name misled me until I read the body of `keepTrying`. + 8. **The overloaded third argument of `WireType.read`**, `pdu.ts:249` (`readParams` passes `sm_length` to every param reader) and `defs/types.ts:280` (`tlvInt`). + - For a mandatory parameter it is `sm_length`; for a TLV it is the TLV header length. + - Only `buffer` and the tlv variants use it, and nothing names the dual meaning. I found it by grepping callers. It stayed half-opaque until then. + +2. **The unit I would least want to modify:** `Reassembler.collect` + `trim` together with `ExpiringGroups.weigh`. + - Weight accounting is spread across `set` (zeroes the weight), `weigh` (evicts, possibly the caller's own key) and `trim` (recomputes the group total from scratch). + - Every refusal path decides a peer-visible status, and a lost group is reported to the application as traffic gone. An off-by-one there is silent data loss. + +3. **Expected hard, found easy:** + - The codec tables (`defs/commands.ts`, `defs/tlvs.ts`) and the TLV typing, including `tlvSpecs` keying each definition to its own name. + - `PduFramer` and `PduTransport`. + - The DLR parsing in `dlr.ts`: every regex and every fallback has a one-line reason. + - `SessionLife` itself, thanks to the diagram. + - GSM packing (153 vs 134). The `segmentUnits` comment, plus the AGENTS section "GSM 7-bit is sent unpacked", made it obvious even to someone who knows nothing about SMPP. + +4. **Prose debt** + - **What I needed and what it cost:** + - The README "SMPP terms" table (ESME/SMSC, `esm_class`, UDH, `sar_*`). Cheap to find, essential without domain knowledge. + - The README sections "Sends and the link", "Receiving in depth" and "Server in depth", to confirm the intent behind items 1 and 4. Each took a scroll-and-search. + - The AGENTS architecture map, for navigation. + - Several `// SMPP 3.4 x.y.z` comments point at a spec I have never read, and I had to take them on trust. Examples: `respIdParams`, `refusalAnswer`, `messageClassOf`. + - The AGENTS decision index gives titles only. Twice (the drain ordering, and `data_coding` ownership in the codec) the title told me a rule existed without telling me the rule, and the reasoning lives in `docs/decisions.md`, which I was told not to open. + - I opened no tests. + - **Prose that told me nothing the code did not:** + - For reading `src/`: the AGENTS test-conventions bullets (fixtures, `resume()`, `t.after` ordering) and the "Defects found in 0.4.0" table, which is history about another codebase. + - The README Goals and Audience. + - A few restating doc comments: `PendingRequests.deliver` ("False means nobody was"), `LinkTimers.clear`'s neighbours, `defs/index.ts`'s grouping. + - The duplicate `emit` / `captureRejectionSymbol` guard comments in `session.ts` and `server.ts`. + +5. **Scores.** Intrinsic difficulty is moderate to high (wire protocol, reconnect, backpressure, multipart), and it gets no bonus below. + - **Navigation 8.** Between 7 and 9: the AGENTS file map plus one concept per file (`dlr-merger.ts`, `link-waiters.ts`, `pdu-refusal.ts`) got me from a symptom to the right file cold every time. The one detour was the second reconnect loop living in `client.ts`. + - **Locality 6.** Between 5 and 7: `SessionLife` does centralise the state, but the answered-or-not state spans `IncomingRequests`, `HandledMessages` and `Sms`. `ExpiringGroups` also pushes enforcement of its own invariants onto three owners, and link identity is a shared `Socket` reference compared across modules. + - **Shape 7.** Predictable: fan-out is bounded (`Session` wires about six collaborators through narrow option objects) and most names tell the truth. It is held there by a few that lie or hide effects: `closing()` mutates, the `settle` callback in `keepTrying` does not settle, and `IncomingRequests.clear()` also empties the handled messages. + - **Self-sufficiency 7.** Predictable: nearly every non-obvious line carries a one-line why, often with a spec reference, and the hard corners are marked. It stays below 9 because several rules (codec `data_coding` ownership, drain ordering) exist in the code as outcomes whose reasons are only indexed titles, and the domain vocabulary needs the README glossary open. + - **Overall 7.** Capped at 7 by Locality 6. A cold mid-level reader knows within a day which corners to fear (items 1, 2 and 3 above). They are few and marked, but item 1 is harder than the problem requires. + +SCORES nav=8 loc=6 shape=7 self=7 overall=7 + +## Draft E, senior seat + +**Comprehension panel report: senior maintainability seat, `@larvit/smpp` (draft-e), whole project** + +I read `README.md`, `AGENTS.md` and every file under `src/`, including `defs/`. I opened no tests. + +## 1. Hardest places, hardest first + +1. **`src/reassembly.ts:187` `Reassembler.trim()`, and `src/expiring-groups.ts:70` `ExpiringGroups.weigh()`** + - `weigh()` can evict the group that is being weighed. `trim()` then has to work out whether that group survived. It also counts only `parts.size - 1` segments as lost when that group is the victim, because the new segment "stays with the peer". + - To get this right I had to hold four things at once: `weigh`'s eviction order, `takeOldest → delete → idle`, the "answered" arithmetic, and the caller in `collect()`, which answers `full`. + - The inline comments made it clear in the end, after two passes. + +2. **`src/expiring-groups.ts:19` `ExpiringGroups`, as a contract** + - The class docstring says it "enforces neither max nor timeout itself", so every owner has to call `full` and `takeExpired()` in the right order. + - `HandledMessages` stretches this across two classes. `IncomingRequests.onMessage` (`incoming-requests.ts:218`) calls `handled.refuses()` before reassembly, and `offer()` comes later, when the message is whole. + - Nothing states that the cap holds only because `refuses()` ran first. I had to reconstruct that myself, and it stayed implicit. + +3. **`src/session-life.ts:115` `SessionLife.enter()` / `:175` `attempt()`, with `src/session.ts:260` `lifeFor()`** + - The ASCII transition table in the header is the best piece of prose in the repo. + - Two things cost me: + - The header says "a transition from any other state is ignored", but `enter()` has no guard. The guards live in the public wrappers (`bound()`, `closing()`, `linkLost()`, `end()`), so I had to check each one. + - The effects are closures defined in `Session`. To follow `down → connected → bound` I had to jump between `session-life.ts`, `session.ts` `lifeFor`/`linkDown`, `OutgoingRequests.linkUp`/`linkLost` and `PduTransport.attach`. + - `attempt()`'s re-check after the await (`this.links !== link || !this.is('connected')`) is commented and fine. + +4. **`src/client.ts:240` `retryUntilBound()` / `:278` `keepTrying()`** + - This is a second backoff loop, separate from `SessionLife`'s. The charter and README say `fromStart` goes "through that same loop", but it doesn't: it's a separate loop that uses the same `Backoff` class. + - The callback type is named `Settle`, but on an error it doesn't settle anything; it only records `lastErr`. You learn that from a comment inside the lambda in `keepTrying`. + - Add the `waiting()` closure and an abort listener, and this is four nested pieces of control flow for one feature. It resolved in the end, but the name misled me along the way. + +5. **`src/pdu.ts:84` `resolveShortMessage()` / `:113` `resolveBody()`** + - The `CodingSource` rule decides which of `short_message` and `message_payload` may rewrite `data_coding`. It depends on whether the command's table has a `short_message` at all, on Buffer vs string, and on zero length. + - The comment on the `CodingSource` type states the rule, but I had to trace three return shapes to confirm it. + - Next to it, `readParams()` (`:240`) passes `sm_length` as the length argument to every param read. That works only because `sm_length` comes earlier in wire order. `commands.ts` documents wire order, but not that this depends on it. + +6. **`src/sms.ts:126` `sendResp()`, with `src/session.ts:304` `answer()`** + - `sendResp` checks the socket the message arrived on, `link.destroyed`. `answer()` then writes to `transport.sock`, the current socket. + - This is correct only because a socket is replaced only after it has been destroyed (`PduTransport.attach`). + - `IncomingRequests.handle()` (`:104`) relies on the same equivalence. It is not stated anywhere I read. + +7. **`src/handled-messages.ts:120` `HandledMessages.run()`** + - Covers expiry, a handler failure, answering after the handler settles, and the identity check `running.get(key) !== sms`, which catches a `clear()` or sweep that ran in the meantime. + - The class docstring covers it. It is compact but dense. + +8. **`src/defs/encodings.ts:163` `messageClassEncoding()` / `encodingByDataCoding()`** + - Bit-twiddling over GSM 03.38 coding groups that I had no background for. + - The comments are adequate. The problem is inherently hard; it is not badly written. Stacking a `//` comment on top of a `/** */` comment made it unclear which one belonged to which function. + +## 2. The unit I would least want to modify + +`Reassembler.trim()` together with `ExpiringGroups.weigh()`. + +- The eviction, the "is the current group gone" question, the lost-segment count and the ESME-facing status (`full` → throttled) are all spread over two files, and each file assumes the other's behaviour. +- A wrong change here silently loses traffic the peer will never resend, which is the README's worst outcome. +- Nothing in the code would stop me. Only a test would catch the mistake. + +## 3. Expected hard, found easy + +- **Framing (`pdu-framer.ts`):** short, and the quadratic-join rationale is right there. +- **The send path:** `OutgoingRequests.request/sendOnce/attempt`. The `written` flag makes "resend or not" a single boolean. +- **`PendingRequests` and `SendWindow`** +- **The TLV table's self-keyed generic** +- **Receipt parsing (`dlr.ts`)** +- **`splitMessage`:** the budget comment together with the charter's "GSM 7-bit is sent unpacked" section settled the 153/134 question at once. + +## 4. Prose debt + +**Needed:** +- The `session-life.ts` state table: essential, and cheap to find. +- `AGENTS.md`'s file map: accurate, and the fastest route from a symptom to a file. +- The charter's "GSM 7-bit is sent unpacked" section. +- README "Server in depth", to understand why segments are answered on arrival. + +**Missing:** +- The invariants in items 2 and 6. They are written down nowhere I was allowed to read. +- The decisions index points at `docs/decisions.md`, which I couldn't open. Twice (the `fromStart` "same loop" wording, and the abort-dance duplication) the one-line index entry made a claim the code did not obviously bear out, and there was nothing local to check it against. + +**Prose that told me nothing new:** +- The `/** Injected so expiry can be exercised without a wall clock. */` comment, repeated on four option types. +- `// Called unbound, so the application's hook never sees this class as its this` (`incoming-requests.ts`). +- Most of the charter's test-convention paragraph (fixtures, `recordingDeps`), which is irrelevant for reading `src/`. +- Much of the defect table, as far as reading `src/` goes. + +## 5. Scores + +- **Navigation 7:** Predictable. The `AGENTS.md` file map is accurate line by line, and file names match their content (`link-waiters`, `pdu-refusal`, `sms-id`), so I landed first try on every symptom I tried. It doesn't reach 9 because `defaults` are applied in three different layers (`Session`, `IncomingRequests`, `Reassembler`), so finding "where does this default apply" takes a search. +- **Locality 6:** Between honest middle and predictable. It is held down by three unwritten cross-class invariants: owners enforce `ExpiringGroups`' limits, `refuses()` must run before `offer()`, and a destroyed arrival socket stands in for the current socket. `SessionLife`'s effects are also closures back into `Session`. +- **Shape 7:** Predictable. Every file is small, fan-out per level is bounded, and names mostly tell the truth. It is kept from 8 by a few names that mislead: + - `closing()` is a transition, not a predicate. + - `Settle` doesn't settle on an error. + - `bound()` exists on two layers with different contracts. + - `onRequest` names both the server's wrapper and the application's hook. +- **Self-sufficiency 7:** Predictable. The why-comments sit on the lines that need them, and spec section numbers are cited. It is held back by a charter index that points to a decisions file whose claims I could not check locally. +- **Overall 6:** The capped maximum is 7 (lowest dimension plus one). I gave 6 because the costs are concentrated in exactly the bounded-store and link-identity code where a mistake loses traffic. + +The problem itself is moderately hard: flow control across two peers, the SMPP body and encoding rules, and reconnect races. That earns no bonus. + +SCORES nav=7 loc=6 shape=7 self=7 overall=6 + +## Draft E, architect seat + +# Comprehension panel, seat "Architect, inherited": @larvit/smpp (draft-e) + +I read README.md, AGENTS.md and every non-test file in src/. I opened no test, ran nothing, edited nothing and ignored the pre-loaded AGENTS.md. + +## 1. Map from README.md and `ls -R src test` only (verbatim) + +> Flat `src/` of 36 files plus `defs/` (7). I expect seven areas that the layout does not show: +> A. **Spec tables**, `defs/`: commands, constants, encodings, errors, TLVs, wire types, plus an index grouping them. +> B. **Codec**: `pdu.ts` (pduToObj/objToPdu), `pdu-framer.ts` (stream to PDUs), `pdu-refusal.ts` (PduRefusedError), `retained-pdu.ts` (?), `result.ts`, `error-from.ts` (?). +> C. **Message content**: `message.ts` (encode/split/bitCount, and probably smppTime), `message-body.ts`, `concat.ts`, `udh.ts`, `reassembly.ts`, `expiring-groups.ts` (a TTL map, probably under reassembly). +> D. **Receipts**: `dlr.ts` (parse *and* build receipts), `dlr-merger.ts` (messageDlr), `sms-id.ts` (hex/decimal ids). +> E. **Session core**: `session.ts`, `session-life.ts` (?), `session-options.ts` (types), `defaults.ts`, `link-timers.ts` (enquire_link/idle), `backoff.ts` (reconnect loop?), `pdu-transport.ts`, `link-waiters.ts` (?), `idle-waiters.ts` (?). +> F. **Request flow**: `outgoing-requests.ts`, `pending-requests.ts` (seq/correlation), `send-window.ts`, `unanswered-error.ts`, `send-sms.ts`, `incoming-requests.ts`, `sms.ts` (the onSms handle), `handled-messages.ts` (messages whose onSms runs, for the bound and the drain). +> G. **Entry points**: `client.ts`, `server.ts`, `index.ts`, `log.ts`, `uuid.ts`. +> Names that do not give their purpose: session-life, link-waiters vs idle-waiters, retained-pdu, error-from, defaults vs session-options, and backoff (a loop or only a delay?). +> Expected from the README but missing: the goal-9 store (the README says it has not shipped). At the repo root, MIGRATION-NOTES.md and DESIGN.md sit beside the six documents the charter names, with no stated purpose. + +### Corrections, cheapest first + +| Map guess | Reality | Cost | +|---|---|---| +| backoff.ts is the reconnect loop | Delay arithmetic only. The loop is the `down` state of `SessionLife`. | Low. The AGENTS table says so. | +| link-waiters / idle-waiters | Requests waiting for a bound link / a count falling to zero | Low | +| dlr.ts parses and builds receipts | Building lives in `sms.ts` (`receiptText`, `sendDlr`, `collectReceipt`) | Medium. I went to dlr.ts first, then grepped for `stat:`. | +| session-options.ts is option types | Also holds `SessionEvents`, bind-direction logic (`bindCarries`, `standsInFor`, `bindCommands`) and all option validation (`checkSessionOptions`) | Medium. The name hides three concerns. | +| Whole-message decoding lives in message.ts | `decodeSegments` is in `reassembly.ts:63` and is called from `sms.ts` | Low-medium | +| One reconnect loop | Two. `SessionLife.attempt/schedule`, plus a second in `client.ts:240` `retryUntilBound`/`keepTrying` for `fromStart`. Both log `'reconnect - retrying'`. | Medium. The same log line from two places is a 3am trap. | +| retained-pdu, error-from | Heap-detach and weight of a held PDU; turning a thrown value into an Error | Low. The AGENTS table covers both. | + +## 2. Fan-out by level + +- **Repo root:** about 8 prose documents (README, AGENTS, CHANGELOG, MIGRATION, MIGRATION-NOTES, DESIGN, todo, CLAUDE) plus 5 directories. MIGRATION-NOTES and DESIGN are not in the charter's "each file answers one question" list. +- **`src/`, the worst level:** 37 entries. They fall into 7 real areas, but only the AGENTS.md table shows that. Without the table this level is past any bound you can hold at once. +- **`src/defs/`:** 7. Good. +- **Within the session:** + - `Session` holds 8 collaborators. + - `OutgoingRequests` holds 3 (PendingRequests, LinkWaiters, SendWindow). + - `IncomingRequests` holds 2 (HandledMessages, Reassembler) plus 6 injected fields. + - `HandledMessages` holds 2 (ExpiringGroups, IdleWaiters). + - Depth is at most 4 and fan-out at most 8 per level. Bounded. +- **Files:** most are under 250 lines. `defs/types.ts` (684 lines) is long but uniform: one wire type after another. + +## 3. Names + +**Misleading:** +- `SessionLife.closing()` (`session-life.ts:97`) reads like a predicate but performs the transition and returns whether it did. `carries()` and `attached()` next to it really are predicates. +- `session-options.ts` holds events, bind direction and validation as well as options. +- The comment on `SessionOptions.shutdownTimeout` (`session-options.ts:117`) says "How long a drain waits for the requests already on the wire". It also bounds the wait on running handlers (`session.ts:324`). That comment is false. +- In `client.ts:234,288`, `Settle` is called once per failed attempt as well as on success, so it does not settle anything. +- `maxOctets` (a public option) bounds reassembly only. The handled-message octet cap is a separate constant, `maxHandledOctets`. + +**One name over several concepts:** +- **`idle`** has four meanings: + - A count reaching zero: `IdleWaiters`, `SendWindow.idle`, `HandledMessages.idle`. + - Stopping the sweep timer: `ExpiringGroups.idle()`. + - The idle-timeout timer: `LinkTimers.idle`. + - The `idleTimeout` option. +- **`settle`** has four meanings: wake all waiters (`IdleWaiters.settle`), wake only if the count is zero (`HandledMessages.settle`), resolve one request (`PendingRequests.settle`), and the local `settle` closures in LinkWaiters, SendWindow and client. +- **`link`** means the socket (`SmsInput.link`, `const link = this.session.sock`), the connection's lifetime (`LinkState`, `LinkTimers`, `LinkWaiters`) and which end of the connection this is (`LinkEnd`). + +**Two names for one concept:** +- The lost link is spelled `linkLost` (the SessionLife transition and `OutgoingRequests.linkLost`) and `linkDown` (the LifeEffects hook and `Session.linkDown`). +- The end of the session is spelled `end()`, the state `ended`, and the effect `over`. +- A message whose handler is running is spelled "handled" (the class and its logs), `running` (its field) and "being handled" (README). + +## 4. What I would restructure, ranked + +1. **Group `src/` into about 6 directories:** `codec/` (pdu, framer, refusal, retained-pdu, defs), `message/` (message, message-body, concat, udh, reassembly), `receipts/` (dlr, dlr-merger, sms-id, and receipt building moved out of sms.ts), `session/` (session, session-life, link-timers, pdu-transport, backoff), `requests/` (outgoing/pending/send-window/link-waiters/idle-waiters, incoming/handled-messages/sms), and top level (client, server, index). Today the AGENTS table does the job the directory tree should do. +2. **One retry loop.** Have `fromStart` reuse the `SessionLife` loop, or give client.ts's loop a distinct name and log line. +3. **Split `session-options.ts`** into `bind-direction.ts` and `option-checks.ts`, and move `SessionEvents` into `session.ts`. +4. **Retire the overloaded verbs** (`idle`, `settle`, `linkLost`/`linkDown`) and rename `closing()` to something like `beginDrain()`. +5. **Deduplicate the collectors.** `collectReceipt` (`sms.ts:182`) is `collectSent` (`send-sms.ts:274`) without ids. + +**What the structure gets right:** +- `SessionLife` is one state value with the transition diagram in its own header (`session-life.ts:7-24`). Effects reach the rest of the session only through `LifeEffects`. +- `OutgoingRequests` reads state through a function and never copies it. +- Every collaborator takes a narrow options object. +- Result types are used throughout, so control flow has no second error channel. +- The comments are dense and mostly say why, not what. +- The AGENTS table matches the code file for file. + +## 5. The 3am question + +**Symptom:** during a graceful shutdown the session hangs until the shutdown timeout, even though the application already called `sms.sendResp()` on every message. + +**Cold path, about 3 to 5 minutes:** +1. `Session.close` (`session.ts:235`) calls `drain` (`session.ts:317`). +2. That calls `this.incoming.drain(timeout)` (`incoming-requests.ts:163`). +3. That calls `HandledMessages.idle` (`handled-messages.ts:106`). +4. The unit is **`HandledMessages.run`** (`handled-messages.ts:120`). A message leaves `running`, which is what releases the drain, only after `onSms`'s promise settles. `sendResp()` records the answer and releases nothing. + +**Likely cause:** the handler is still awaiting something, typically `sms.sendDlr()`. That goes through `handlers.send`, which is `outgoing.request`: it bypasses the closing-state refusal and waits up to `responseTimeout` (30 s), which is longer than `shutdownTimeout` (5 s). + +**What gets you there:** the README (lines 107-109) and the `OnSms` doc (`session-options.ts:87-92`) both say the handler's promise is the hold. **What slows you down:** the `Sms.sendResp` doc (`sms.ts:47-51`) says nothing about the hold, and the `shutdownTimeout` comment wrongly claims it only bounds requests. + +**Where it rots first:** the wiring in `Session`'s constructor and `transportFor`/`lifeFor`/`incomingFor` (`session.ts:105-294`). +- Eight collaborators are cross-wired through closures that capture `this.life` and `this.transport` before those are assigned. That is safe today only because of construction order. +- `IncomingRequests` reaches back into `Session` for `sock`, `close()`, `emit`, `bindAllows`, `boundAs` and `linkEnd`, so the dependency runs in both directions. +- Every lifecycle feature touches three files: session-life (transition), session.ts (effect) and the collaborator. + +**Where the next two features land:** +1. **The goal-9 store** lands under `ExpiringGroups`, which has three owners: `DlrMerger`, `Reassembler` and `HandledMessages`. Each wraps it differently (weigh-evicts, a "spent" set, sweep callbacks), so a persistent seam would have to be cut three times or `ExpiringGroups` would have to become the store interface. +2. **A per-PDU rate-limit hook** lands in `OutgoingRequests.sendOnce` (`outgoing-requests.ts:116`), between the window acquire and `attempt`, and must respect the rule that a written request is never retried. The code states that rule, so the change is contained. An alphabet hook would be far worse: `EncodingName` is a closed union that ripples through defs/encodings, message.ts and send-sms.ts. + +## 6. Hardest places, ranked + +1. `session.ts:105-294`, `Session` constructor plus `transportFor`/`lifeFor`/`incomingFor`: closure wiring, and initialisation order matters. +2. `session-life.ts:175`, `SessionLife.attempt`: re-enters after two awaits and guards with the `links` counter plus `is('connected')`. It is correct, but you have to hold the whole diagram to read it. +3. `session.ts:218`, `Session.unbind`: the `wasOpen`/`closedOnUnbind` arithmetic and the order of error precedence. +4. `outgoing-requests.ts:97-183`, `request`/`sendOnce`/`attempt`: a `for(;;)` retry keyed on `written` and a fresh `state()` read. +5. `handled-messages.ts:62,120`, `refuses()` (a query that mutates the hysteresis flag and sweeps) and `run()` (answer after settle, then conditional delete). +6. `pdu.ts:84-136`, `resolveShortMessage`/`resolveBody`: which field's encoding sets `data_coding`. +7. `reassembly.ts:187` `trim` with `expiring-groups.ts:70` `weigh`: eviction can take the current key, with off-by-one accounting of lost parts. +8. `client.ts:240-309`, `retryUntilBound`/`keepTrying`: the second loop and its misnamed settle callback. + +**The unit I would least want to modify** is `SessionLife.enter` (`session-life.ts:115`) together with its effect bindings in `Session.lifeFor` (`session.ts:260`). One transition's meaning is split across two files and five callbacks. + +**Intrinsic difficulty is high**, and none of the scores below credit it: an async protocol with reconnect, drain, correlation, reassembly and a byte-exact codec against peers that do not follow the spec. + +## 7. Scores + +- **Navigation 7:** at the "Predictable" anchor. The AGENTS file table plus accurate file names got me from the 3am symptom to `HandledMessages.run` in minutes. It sits no higher because the 37-file flat `src/` depends on that table, and two parallel retry loops share one log line. +- **Locality 6:** between "Honest middle" and "Predictable". `LifeEffects` and the `state()` accessor are real seams. Holding it down: `IncomingRequests` reaches back into `Session`, the constructor wiring depends on order, and one lifecycle change spans session-life, session and a collaborator. +- **Shape 6:** between 5 and 7. Nesting below the top level is bounded (8 at most per level). Holding it down: the 37-entry top level, a misnamed `session-options.ts`, a mutating `closing()`, and `idle`/`settle`/`link` each covering several concepts. +- **Self-sufficiency 7:** at "Predictable". The state diagram, invariant comments and "why" comments sit at the code. Holding it back from higher: the false `shutdownTimeout` comment (`session-options.ts:117`), and `Sms.sendResp` not saying it does not release the drain. +- **Overall 6:** capped at 7 by the lowest dimension plus one. The hard parts are marked but spread across the session wiring, and the top level needs a document to map it. + +SCORES nav=7 loc=6 shape=6 self=7 overall=6 + +## Draft F, junior seat + +**Junior A: comprehension report for draft-f** + +I read README.md, AGENTS.md and every file under `src/`, all in draft-f. I opened no test. + +## 1. Hardest places, hardest first + +1. **`src/expiring-groups.ts:38-54, 111`: the `ExpiringGroups` getters `full`, `size` and `weight`, and `get()`, which all call `expire()`.** + - Reading a property has side effects. It can fire `onDrop`. + - In `Reassembler`, `onDrop` becomes `onLost`, then `port.report`, then a `sessionError` emit. In `RunningHandlers` it logs a warn and settles the drain's waiters. + - So `this.running.size` in `IncomingRequests.refusedAtBound` (`incoming-requests.ts:224`) can emit events on the application's emitter. I only found this by following `onDrop` through three classes. + - No comment at the call sites says so. It stayed a trap even after I understood it. + +2. **`src/reassembly.ts:113` (`Reassembler.collect`) with `weighed()` at `:180`, `dropped()` at `:194` and the `weighing` field at `:91`.** + - `weighing` is a side channel. It is set around a `weigh()` call so that the drop callback, which fires synchronously inside it, can subtract the newest segment from the reported loss. + - `weighed()` then reads `get(key)` again to find out whether its own group was evicted. + - To follow it I had to hold several things at once: + - part and total validation; + - a group that is new or already there; + - eviction by count inside `set`; + - eviction by weight inside `weigh`; + - the "newest segment stays with the peer" rule. + - The field's comment explains why but not how. It was resolved only after a second read. + +3. **`src/incoming-requests.ts:260` (`onMessage`) and `:292` (`handOver`), together with `sms.ts:126` (`sendResp`) and `:196` (`sendDlr`).** + - A message's answer is spread over four places: + - answered on arrival for segments; + - the handler's return; + - an early `sendResp()`; + - the refusal on a throw, which is itself split by `answeredOnArrival`. + - The shared state is the mutable `answer` object captured in the closures of `createSms` (`sms.ts:80`). + - `throttledStatus` versus `refusedSegmentStatus` needs the `carriedAs` / `standsInFor` indirection for `data_sm`. + - The README's "Receiving in depth" section resolved it. The code alone did not. + +4. **`src/pdu.ts:84` (`resolveShortMessage`) and `:113` (`resolveBody`).** + - `CodingSource` decides which of `short_message` and `message_payload` may rewrite `data_coding`. + - There are five return paths, depending on Buffer or string, empty or not, and a string `message_payload`. + - `data_coding` is patched onto the params from two places. + - The type comment at `:74` helps, but I had to trace each branch by hand. It remained partly opaque, for example why an empty encoded string falls to `message_payload`. + +5. **`src/defs/encodings.ts:152-191`: `messageClassOf`, `messageClassEncoding`, `encodingByDataCoding`.** + - Bit masks (`& 0x80`, `>> 2 & 0x03`, `0xF0`) that I had no background for. + - Two comments are stacked oddly at `:160-162`: a `//` block and then a `/** */`, both describing the function below. + - It stayed opaque without the GSM 03.38 spec. The intrinsic difficulty is high. + +6. **`src/defs/tlvs.ts:110` (`WriteValue`) and `:284` (`keyedTlvs`, with the `isTlvs` guard).** + - Conditional types nested three deep, plus a runtime re-validation of what the code just built, because casts are banned. + - Hard rule 4 in AGENTS.md explains why the guard exists. The type gymnastics remained costly. + +7. **`src/reconnect-loop.ts:82` (`schedule`), `:127` (`attempt`) and `:64` (`adopt`).** + - `upAt` resets the backoff only after a link lasted `maxDelay`. + - `links > 0` decides `unref`. + - A `stopped` check comes after an `await` in `attempt`. + - There are three flags (`timer`, `attempting`, `stopped`) guarding re-entry. + - The comments explain each rule, so it was resolved, but it is order-sensitive. + +8. **`src/send-window.ts:42` (`release`).** Handing a slot to a waiter without decrementing `inFlight` is correct but not commented. I had to reason out that the slot transfers. + +## 2. The unit I would least want to modify + +`Reassembler.collect` / `weighed` / `dropped`. A change to eviction order inside `ExpiringGroups.weigh`, or to when `get()` expires, silently changes the loss counts reported to the application, which are sessionError events. Nothing at the call site tells me that the coupling exists. + +## 3. Expected hard, found easy + +- **`PduFramer`:** short, one clear purpose. +- **`PendingRequests` and `OutgoingRequests`:** the split between `LinkLostError` ("never written, retry") and `UnansweredError` ("may have been taken") is named well. `SmppClient.send`'s retry loop (`client.ts:143`) read at once. +- **The layering of Session, IncomingRequests and SessionPort:** the port type (`incoming-requests.ts:50`) says exactly what the collaborator may touch. +- **`server.ts` `handleRequest`:** bind-before-anything is compact. +- **`defs/types.ts`:** long but repetitive and uniform. + +## 4. Prose debt + +**What I needed, and what it cost to find:** + +- **README "Glossary":** needed for ESME/SMSC, `esm_class`, `data_coding`, UDH and `sar_*`. It sits about 600 lines down the README and nothing in `src/` points at it. +- **README "Receiving in depth" and "Server in depth":** needed to understand the answer-on-arrival model before `onMessage` made sense. +- **AGENTS "GSM 7-bit is sent unpacked":** needed to believe the 153/134 budget in `message.ts` (the `segmentUnits` constant near the top). +- **The `ASCII` encoding name:** it means GSM 03.38. Only the comment in `encodings.ts` near line 45 and a README table say so. The name lies. +- **Bit layout of `esm_class` and `data_coding`:** not documented anywhere I was allowed to read. I would need the spec. + +**Prose that told me nothing the code did not:** + +- AGENTS' architecture map repeats most file-level doc comments almost word for word. It was still useful as an index. +- Several Conventions paragraphs about tests (`dummy-smsc`, `recordingDeps`) were irrelevant to reading `src/`. +- AGENTS' decision index names `ReconnectOptions`, which does not exist in `src/`. The type is `ReconnectTuning`. That line is stale. +- Comments that add nothing: + - `'Whether the socket has closed.'` on `closed` (`session.ts:148`); + - `'Sends a request and resolves with the peer's response.'` (`session.ts:172`); + - `'Connects to an SMSC and binds.'` (`client.ts:414`). + +## 5. Scores + +| Dimension | Score | Anchor and cause | +|---|---|---| +| Navigation | 7 | Predictable. AGENTS' one-line-per-file map and honest file names (`pdu-framer`, `dlr-merger`, `send-window`) took me from a symptom to a file first time. `ASCII` meaning GSM and the three meanings of "refuse" and "answer" (`Session.refuse`, `IncomingRequests.refuse`, `sendReturn` / `answer` / `sendResp`) keep it off 8. | +| Locality | 5 | Honest middle. The getters in `ExpiringGroups` fire callbacks that emit on the session. The `weighing` side-channel field depends on synchronous re-entry. The mutable `answer` closure is shared by `sendResp` and `sendDlr`. Changing one piece means holding its callback chain. | +| Shape | 6 | Between honest middle and predictable. Fan-out is bounded (Session builds four collaborators; IncomingRequests builds two). Some names mislead: `ASCII`; `stopping`, which the peer's unbind also sets; `over` versus `closed`; `SessionListener`'s emit guard copied three times. | +| Self-sufficiency | 6 | Between honest middle and predictable. Inline spec citations ("SMPP 3.4 5.3.2.26", "4.6.2") and why-comments mostly carry it. The answer-on-arrival model and the data_coding bit groups still need the README or the spec open beside the code. | +| Overall | 6 | Capped by Locality (5 + 1). | + +**Intrinsic difficulty:** the problem is hard, and gets no bonus in these scores. It involves concurrency, the protocol's split between UDH and `sar_*`, receipts that look like messages, and the GSM alphabets. + +SCORES nav=7 loc=5 shape=6 self=6 overall=6 + +## Draft F, mid seat + +1. **Hardest places, ranked hardest first** + +- **`src/reassembly.ts:330` `Reassembler.weighed()` / `dropped()` (:344), together with `src/expiring-groups.ts:38-54` (the `full`, `size` and `weight` getters).** `weighed()` sets `this.weighing` so that `dropped()`, reached re-entrantly through `ExpiringGroups.weigh()` → `dropOldest()` → `onDrop`, knows to subtract the newest segment from the loss count. That is a side channel through a field. On top of it, every getter on `ExpiringGroups` runs `expire()`, which fires `onDrop`. So reading `size` in a log line (`reassembly.ts:358`, `this.groups.weight` inside `lost()`) can drop other groups and report them mid-report. The comments at :240 and :274 got me to what it intends. Whether the re-entrant drops are harmless stayed unresolved. +- **`src/session.ts:211-332` `Session.unbind()` / `drain()` / `finish()` / `end()`.** Three flags carry a lifecycle: `over`, `stopping` and the `ended` promise. `closed` is a getter over `over`, and `port().end` (:262) sets `stopping` from outside the drain. `unbind()`'s `droppedOnUnbind` depends on `UnansweredError` and `this.over` agreeing after an await. The field comments (:64, :66) and the class doc resolved which flag means what, but only after I built a table by hand. +- **`src/client.ts:515` `SmppClient.send()`.** It loops on `LinkLostError`. Whether it terminates depends on `ReconnectLoop.bound()` (which hands back the current session until the socket's `close` event), `Session.send()` (which checks `over`, set only on `close`) and `PduTransport.write()` (which fails on `sock.destroyed`). A socket that is destroyed but has not yet emitted `close` looks to me like it gives a loop that resolves only through microtasks and may never yield. I could not rule that out without a test. This is the plainest action-at-a-distance in the codebase, and it stayed opaque. +- **`src/incoming-requests.ts:260-351` `onMessage()` → `handOver()` → `refuse()`, with `src/sms.ts:796-860` `createSms()` / `sendResp()`.** I had to hold several things at once: + - whether the message was answered on arrival; + - whether the handler threw; + - whether it returned `{ smsId }` or `{ status }`; + - the `Answer` object mutated inside the closure; + - `carriedAs` choosing the retry status. + + `alreadyAnswered` has four error texts for these combinations. The README section "Receiving in depth" resolved it; the code alone did not. +- **`src/defs/encodings.ts:152-191` `messageClassOf()`, `messageClassEncoding()`, `encodingByDataCoding()`.** This is bit arithmetic on a GSM 03.38 layout I have never seen. There is a `//` comment and then a `/** */` comment stacked on one function (:160-162), and the `//` one reads as though it belongs to the function above. The comments give the bit positions. Why 0x03 means one thing below 0x80 and another in a class group stayed half opaque. +- **`src/dlr.ts:157-238` `messageType()` / `receiptStatus()` / `dlrFromPdu()`.** `'unmarked'` versus `'receipt'`, whether the TLV or the body wins, and `statusMsg` possibly being `undefined` before it falls back to `'UNKNOWN'`. The README's Delivery receipts section resolved it. Without the README I would have guessed wrong about `'other'`. +- **`src/pdu.ts:323-375` `resolveShortMessage()` / `resolveBody()`.** The `CodingSource` naming feels inverted: an empty `short_message` yields `source: 'message_payload'`, meaning "`message_payload` may set `data_coding`". The comment at :313 explains it, but I had to read it twice. +- **`src/reconnect-loop.ts:83` `schedule()`.** It resets the backoff only if `upAt` shows the link outlasted `maxDelay`, clears `upAt`, doubles the delay after capturing it, and calls `unref()` only once a link has existed. Each step has a comment, which resolved it. It is dense rather than opaque. + +2. **The unit I would least want to modify: `ExpiringGroups` (`src/expiring-groups.ts`).** Three owners (`Reassembler`, `DlrMerger` with its `spent` store, and `RunningHandlers`) depend on when drops happen and in what order. Reads mutate state and fire callbacks. `set()` can evict. `weigh()` can evict the entry being weighed. Any change moves loss accounting, drain wake-ups and receipt-merge refusal all at once. Note also that `RunningHandlers` logs "giving up on a handler that never returned" for an *eviction*, not only an expiry. + +3. **Expected hard, found easy** + - The wire codec: `defs/types.ts`, `pdu.ts` parse/build, TLV read/write. It is long but regular: every reader range-checks, and every error names the parameter. + - `PduFramer`. + - `PendingRequests` and `SendWindow`. + - The server's bind handling (`handleRequest`). + - The option validation in `session-options.ts`. + + Result-typed code with no throws made control flow easy to follow. + +4. **Prose debt** + - **Needed:** + - the README Glossary for ESME, SMSC, `esm_class`, `data_coding`, UDH and `sar_*` (cheap to find, essential for me); + - the README "Receiving in depth" and "Server in depth" sections, for the answer rules in `sms.ts` and `incoming-requests.ts` (about 10 minutes to locate); + - the AGENTS "GSM 7-bit is sent unpacked" section, for `segmentUnits` 153 versus 134. + + The AGENTS decision index names choices, such as "A message id base is merged at most once", whose reasoning lives in `docs/decisions.md`, which I was not allowed to open. For a few (the `spent` store and `LinkLostError` retry), the title alone left me unsure whether the behaviour I saw was the intent. The charter also says `IncomingRequests` and `Sms` get "named functions, never the session itself". That is false as written: `SessionPort.session` and `Sms.session` both hand over the full `Session` (`incoming-requests.ts:65`, `:293`). + - **Told me nothing:** + - most AGENTS architecture one-liners, which restate the file names; + - the seven `declare` listener lines, repeated in all three emitters; + - `/** Whether the socket has closed. */` on `closed`; + - `defaults.ts`'s "README's option tables restate the public ones"; + - many decision-index bullets that simply restate what the code shows, such as "`reconnect` takes `{ minDelay, maxDelay }`…". + + Intrinsic difficulty: moderate to high. Two alphabets' bit layouts, two concatenation spellings, and receipts sharing a command with messages are the problem's own difficulty, not the code's. They get no bonus. + +5. **Scores** + - **Navigation: 7.** At the "predictable" anchor: `src/` is flat, file names follow concepts (`dlr-merger`, `pdu-framer`, `send-window`), and the AGENTS map matched the tree exactly. It stops short of 8 because of placements like `leftOf` in `idle-waiters.ts` and `refusedSegmentStatus` exported from `incoming-requests.ts`. + - **Locality: 5.** At the "honest middle": getters with side effects in `ExpiringGroups`, the `weighing` side channel in `Reassembler`, and `SmppClient.send()` being correct only across the timing of three modules are all state changed out of sight. + - **Shape: 6.** Between 5 and 7. Files are small and fan-out is bounded, but the vocabulary is overloaded: + - answering: `answer`, `sendReturn`, `sendResp`; + - refusing: `refuse` in two classes with different meanings, plus `refusedAtBound`; + - ending: `over`, `closed`, `stopping`, `end`, `finish`, `ended`; + - and `SessionPort` claims a narrow seam while carrying the whole `Session`. + - **Self-sufficiency: 6.** Between 5 and 7. Comments cite SMPP sections and state the why in place (for example `respIdParams` and `refusalStatus`). But the answer rules and receipt classification needed README sections open beside them, and the decision index points at reasoning that is not in the code. + - **Overall: 6.** Capped by locality (5 + 1). The code reads cleanly line by line; what costs is the few places where state moves out of sight. + +SCORES nav=7 loc=5 shape=6 self=6 overall=6 + +## Draft F, senior seat + +1. **Hardest places, ranked** + + 1. **`src/incoming-requests.ts:260-351`, `IncomingRequests.onMessage` / `handOver` / `refuse`, plus `refusedAtBound` at :224.** Before I could predict the answer to one inbound PDU, I had to hold eight branches at once: + - the running-handler bound; + - concatenated or whole; + - kept or refused, and full or unplaceable, with the refusal status differing between sar and UDH; + - whole or partial; + - link already closed; + - no `onSms`; + - the handler threw or returned; + - answered on arrival or not. + + "Who answers the peer, and when" is spread over `onMessage`, `handOver`, `refuse`, `createSms(answeredAs)` and `sms.sendResp`/`alreadyAnswered` in another file (`sms.ts:106-144`). `refusedAtBound` returns a boolean but also writes the answer and flips the `refusing` flag. The comment at :256 and README "Receiving in depth" / "Server in depth" resolved it, but only after two passes. + 2. **`src/reassembly.ts:91,113-137,180-198`, `Reassembler.collect` / `weighed` / `dropped`.** The `weighing` field is a side channel. It is set around `groups.weigh()` so that the synchronous `onDrop` callback, which re-enters `dropped()`, can subtract the newest segment from the reported loss. `collect` also inserts the segment into `group.parts` before it knows whether the group survives the weighing. The comments at :90 and :124 made it resolvable, but only by tracing the re-entrancy by hand. + 3. **`src/expiring-groups.ts:250-266,295-306,323-334`, the `ExpiringGroups` getters `full` / `size` / `weight` and `weigh`.** Reading a property runs `expire()`, which fires `onDrop` callbacks. Through `RunningHandlers` (`running-handlers.ts:259`) that means a plain read of `this.running.size` inside a log call in `refusedAtBound` (`incoming-requests.ts:229`) can log a "giving up on a handler" warning and wake a drain. The class comment says "the expired go on every access". Nothing at the call sites marks it. This stayed partly opaque: I am not certain every caller tolerates it. + 4. **`src/session.ts:211-221,291-332`, `Session.unbind` / `drain` / `finish` / `end`.** + - There are three lifecycle flags: `over`, `stopping` and `bind`. + - `stopping` is also set from outside, through `SessionPort.end` (:262). + - `drain` reads the same state as `this.over` at :294 and as `this.closed` at :303. + - `unbind` deliberately bypasses `send()`'s stopping check by calling `outgoing.request` directly, uncommented. + - `droppedOnUnbind` needed its docblock plus the charter's decision index ("a close arriving after our own unbind is clean") before I trusted it. + 5. **`src/client.ts:143-156,202-217,415-438` with `src/reconnect-loop.ts:64-153`, `SmppClient.send` retry loop / `takeFirst` / `keepTrying` / `ReconnectLoop`.** The `LinkLostError` contract spans four files. `send-window.close` sets it for queued requests and `OutgoingRequests.attempt` sets it on a write failure (`outgoing-requests.ts:269,287`). `SmppClient.send` consumes it, with `ReconnectLoop.bound`/`release` in between. The first session is opened outside the loop and then `adopt`ed. `links === 1` means "first", and `links > 0` decides `unref()`. Resolved by the `LinkLostError` class doc and the README's "Sends and the link". + 6. **`src/pdu.ts:74-136`, `resolveShortMessage` / `resolveBody`.** The rule for which of `short_message` and `message_payload` may overwrite `data_coding` uses `CodingSource`. An empty Buffer counts as `message_payload`, and only a non-empty encoded `short_message` rewrites the coding. I read it three times. The `CodingSource` doc resolved it. + 7. **`src/defs/encodings.ts:476-515`, `messageClassOf` / `messageClassEncoding` / `encodingByDataCoding`.** This is bit arithmetic over GSM 03.38 coding groups that I had no background in. A `//` comment and a `/** */` for different concerns are stacked on one function (:484-486). The comments carry the rule, so it was domain cost rather than code cost. + 8. **`src/defs/tlvs.ts:103-126,284-306`, the `WriteValue` / `Repeated` conditional types and `keyedTlvs` → `isTlvs`.** The type layer takes a slow read. The runtime re-validation that ends in "a defect in this library" exists only to avoid a cast. I understood why only through hard rule 4 in the charter. + +2. **The unit I would least want to modify:** `IncomingRequests.onMessage`/`handOver` together with `sms.ts` `sendResp`/`alreadyAnswered`. The invariant "every PDU gets exactly one answer, and no answer names an id or refusal after the segments were answered" is not stated in one place. It is enforced jointly by the `answeredAs` argument, the mutable `answer` object captured in `createSms` closures, the `closed()` check placed after `createSms`, and `refuse`'s `answeredOnArrival` branch. A change to any one of these can double-answer or silently drop, and only a test would tell me. + +3. **Expected to be hard, found easy:** + - The codec: `pdu.ts` read/write, `defs/types.ts` wire types, `pdu-framer.ts`. It is long but uniform and bounds-checked the same way everywhere. + - `PendingRequests`, `SendWindow` and `IdleWaiters`: small, one job each. + - `dlr.ts` receipt parsing, with the operator quirks commented inline. + - `send-sms.ts`: a linear checklist, then fan-out. + - The server bind composition in `server.ts:508-533`. + +4. **Prose debt** + - **Needed:** + - README "Receiving in depth" and "Server in depth", for answered-on-arrival semantics and the retry statuses. Found by the table of contents, so the cost was low. + - The charter's architecture map. It was the most valuable document: every file is listed with a truthful one-liner. + - The decision index titles. They told me that behaviours like the unbind-close and five-minute handler were deliberate. With `docs/decisions.md` off-limits, a title was sometimes all I had. + - I opened no tests. + - **Told me nothing new:** + - The AGENTS.md "Defects found in 0.4.0" table (history, irrelevant to reading `src/`). + - The long test-fixture convention paragraphs. + - README "Methods", which lists names already typed. + - Comments that restate code: + - `session.ts:148` "Whether the socket has closed." + - `session.ts:172` "Sends a request and resolves with the peer's response." + - `server.ts:631` "Starts listening… Resolves once the socket is bound." + - `tlvs.ts:14` "Ordered by tag id", which repeats the charter. + - The seven `declare` listener lines, copied across three emitters, are boilerplate rather than prose, but they are reading cost all the same. + +5. **Scores** + - **Navigation 8:** between "predictable" and "near duress-proof". AGENTS.md's file map matches `src/` one-to-one, and names like `pdu-refusal.ts`, `send-window.ts` and `dlr-merger.ts` lead from a symptom to the file first try. The detour is the answer path, split across `incoming-requests.ts` and `sms.ts`. + - **Locality 6:** between "honest middle" and "predictable". The seams are named and narrow (`SessionPort`, `SmsDeps`, `SendSmsDeps`, `LinkLostError`). Three things still break locality: + - `ExpiringGroups` getters fire callbacks on read. + - `Reassembler.weighing` is a re-entrancy side channel. + - `Session`'s flag trio is mutated through `port.end`. + - **Shape 7:** "predictable". No file is past about 440 lines and fan-out per level is small. A few names mislead: + - `ExpiringGroups` is used for running handlers and spent ids, which are not groups. + - `closed` and `over` are two names for one state. + - `refusedAtBound` answers the peer as a side effect. + - `ReconnectLoop.attempt`'s comment calls the library's own connect and bind "the application's". + - **Self-sufficiency 7:** "predictable". Comments carry the SMPP section and the why at the non-obvious points (`sms-id.ts:623`, `message.ts:13`, `dlr.ts:247-253`, `pdu-refusal.ts:446`). The answered-on-arrival contract is the one thing that needed the README open beside the code. + - **Overall 7:** capped at 7 by locality. A cold senior is productive within a week and knows which corners to fear. Intrinsic difficulty is moderately high (protocol quirks plus a concurrent drain and reconnect), and that earns no bonus. + +SCORES nav=8 loc=6 shape=7 self=7 overall=7 + +## Draft F, architect seat + +**Architect, inherited: comprehension report on @larvit/smpp (draft-f)** + +## 1. Map from README and tree only (verbatim, written before opening any source) + +Top-level areas I expect, although `src/` is flat and shows none of them: +1. **Public handles**: `index.ts`, `client.ts` (SmppClient, reconnecting), `server.ts` (listener, one Session per connection), `session.ts` (one socket's life), `sms.ts` (the inbound `sms` handle with sendResp/sendDlr). +2. **Session machinery**: `link-timers` (enquire_link and idle), `reconnect-loop` (backoff), `send-window` (maxOutstanding), `pending-requests` (seqNr correlation and timeout), `outgoing-requests` (window plus pending), `incoming-requests` (dispatch of peer requests), `running-handlers` (onSms handlers in flight, "Handlers still running" in the README), `idle-waiters` (maybe the idle timeout?), `pdu-transport` and `pdu-framer` (socket to PDUs). +3. **Codec**: `pdu.ts`, `pdu-refusal` (PduRefusedError), `retained-pdu` (a PDU kept for retry?), and `defs/` for the spec tables and wire types. +4. **Message content**: `message.ts` (encode, split, bitCount, smppTime), `message-body` (short_message vs message_payload), `concat` and `udh` (probably the reading and writing of concatenation), `reassembly`, `expiring-groups` (the reassembly store?), `send-sms` (submit composition). +5. **Receipts**: `dlr.ts`, `dlr-merger` (messageDlr), `sms-id` (notations, `-`). +6. **Plumbing**: `result`, `log`, `error-from`, `uuid`, `defaults`, `session-options`, `unanswered-error` (went out, no answer), `link-lost-error` (never reached the socket). + +Names that do not give their purpose: `idle-waiters`, `retained-pdu`, `expiring-groups`, `error-from`, and `defaults` vs `session-options`. + +What the README led me to expect: a store interface (goal 9). The README itself says it has not shipped, so its absence is fine. + +## Where the map was wrong, and what each correction cost + +- **`idle-waiters`** is a primitive that waits for a count to fall to zero. It is not the idle timeout, and it also exports `leftOf()`, the deadline arithmetic `client.ts` uses. Cost: low. The misleading part is `leftOf` living there. +- **`retained-pdu`** copies a PDU off the wire so holding it does not pin the chunk, and weighs what holding it costs. It has nothing to do with retry. Cost: low. The copy invariant is split with `defs/tlvs.ts:250`, which already copies TLVs, so `retained-pdu.ts:5`'s claim that "wire reads hand back views" is only half true. +- **`expiring-groups`** is a generic capped, weighed, expiring map. Besides reassembly it also backs `DlrMerger` (twice) and, surprisingly, `RunningHandlers` as a counter (`ExpiringGroups` keyed by serial). Cost: medium. "Groups" misleads for the handler counter. +- **`concat` / `udh`**: splitting is in `message.ts`. `udh.ts` holds the outgoing `ConcatReference` counter plus the parse `concatInfo`. `concat.ts` chooses between UDH and `sar_*`. Cost: medium. It took three files to place the concatenation concepts. +- **`session-options`** is not only options. It also holds bind-direction policy (`bindCarries`, `standsInFor`), `SessionEvents`, the hook types, and validation for client- and server-only options (`authenticate`, `connectTimeout`, `reconnect`, `fromStart`). Cost: medium. I would never have looked there for "which way does a data_sm travel". +- **`pdu.ts` depends on `message.ts`** (`encodeBody`, `decodeMessage`), which depends on `udh.ts`. So the codec sits above the message layer, not just above `defs/`. Cost: low, but the layering in the AGENTS text is incomplete. +- The rest of the map held. + +## Fan-out, level by level + +- **L0, the repo:** `src`, `test`, `docs`, `benchmarks`, `interop-tests`, plus about 8 top-level `.md` files. Fine. +- **L1, `src/`:** 36 files plus `defs/`, so 37 entries. **This is the worst level.** About six real areas exist, but the layout shows none of them. The only map is the Architecture block in AGENTS.md. +- **L2, `defs/`:** 7 files, clean. +- **L3, the big units:** + - `Session` composes 4 collaborators plus a hand-built `SessionPort` of 12 members. + - `IncomingRequests` holds `Reassembler`, `RunningHandlers`, `createSms`, the port and the hooks, and imports 23 symbols. + - `SmppClient` holds `ReconnectLoop`, `DlrMerger` and `ConcatReference`, plus about 200 lines of free connect and bind functions. + +## Names + +**Names that mislead** +- `IncomingRequests.drain` (`incoming-requests.ts:147-153`) calls the count of running handlers `unanswered` and reports "Shut down with N message(s) unanswered". A handler that already called `sendResp()` is still counted. That is the 3am bug's own error text pointing the operator at the wrong thing. +- `RunningHandlers`' `onDrop` logs "giving up on a handler that never returned" for any drop, including an `evicted` one. Eviction can only be avoided because `refusedAtBound` is checked first, somewhere else (`running-handlers.ts:28`). +- `session-options.ts`, as above. +- `decodeSegments` lives in `reassembly.ts` but is used by `sms.ts`. +- `ExpiringGroups` used as a counter. + +**One name over several concepts** +- **refuse:** `Session.refuse` (codec-refused PDU), `IncomingRequests.refuse` (application did not take the message), `refusedAtBound`, `Refusal` (a reassembly slot), `refusedSegmentStatus`, `refusalAnswer`, `PduRefusedError`. +- **end:** `Session.end`, `OutgoingRequests.end` and `IncomingRequests.end` all mean "the socket is gone". `SessionPort.end` means "the peer unbound, destroy the socket". `SmppClient.end` means "the client is over". +- **answer:** `SessionPort.answer` writes a response. `sms.ts`'s `Answer` is mutable answered-state. `answerOf` converts the handler's return. +- **closed:** a public getter on `Session`, a private field on `SmppClient`, and a port function. + +**One concept with two names** +- Session lifecycle: `over` / `closed`, and `stopping` / "shutting down". +- The UDH indicator is checked through `hasUdh` in 5 places, and the body is sometimes `params.short_message` (a string, or a Buffer when a UDH is present) and sometimes `shortMessageOctets`. +- The submit bind check is spelled twice: `client.ts:159` reads `options.bindType`, `session.ts:195` calls `bindAllows`. + +## Restructure, ranked + +1. **Split `IncomingRequests`.** Routing (`route`, `unhandled`, `onDelivery`) is one piece. Message intake (bound refusal, reassembly, answer-on-arrival, `handOver`, `refuse`) is a second, called something like `MessageIntake`. It is the densest unit and the one that grows. +2. **Carve bind-direction policy out of `session-options.ts`** into `bind-direction.ts`, and move client/server option validation next to its owners or into an `option-checks.ts`. +3. **Make the "drain waits on" counter say what it counts.** Either release it on `sendResp()` plus pending receipts, or rename the error to "handlers still running". Also stop using `ExpiringGroups` as the handler counter. +4. **Put concatenation in one module:** `ConcatReference`, `concatInfo`, `concatOf`, `udhLength`, and the UDH build in `splitMessage`. +5. **Group `src/` into 4–5 folders:** handles, link, codec, message, receipts. The flat 37 files is the main Shape cost. +6. **Extract the emitter guard.** The `emit` override, `captureRejectionSymbol` and 7 `declare` lines are copied three times (Session, SmppClient, SmppServer). + +## What the structure gets right + +- Narrow seams: `SessionPort`, `SmsDeps`, `SendSmsDeps`, and the `ReconnectLoopOptions` callbacks. Collaborators do not reach into the Session. +- Every unit is small and single-noun (`SendWindow`, `PendingRequests`, `LinkTimers`, `PduFramer`). +- `Result` is used everywhere, so control flow reads top-down. +- Comments carry spec sections and the peer quirks behind them (Jasmin, CM.com, Kaleyra). +- `defaults.ts` is the single source of numbers. +- `LinkLostError` vs `UnansweredError` encodes goal 2 in the type. + +## The 3am question + +**Time to the right unit, cold: about 5–10 minutes, three hops.** +- Hop 1: grep "drain" lands in `session.ts:291`, `Session.drain`. +- Hop 2: that calls `this.incoming.drain(timeout, signal)` at `incoming-requests.ts:146`. +- Hop 3: that calls `RunningHandlers.idle` (`running-handlers.ts:63`), and I had to find where `start()` and `done()` are called: `IncomingRequests.handOver`, `incoming-requests.ts:311-314`. + +**The answer:** `done()` fires when the `onSms` handler *returns*, not when `sms.sendResp()` is called. `sendResp` (`sms.ts:126`) never touches `RunningHandlers`. So a handler that answers early and keeps working holds the drain until `shutdownTimeout`. That includes a handler awaiting `sendDlr()` to a slow peer: `sendDlr` goes through `port.request`, which bypasses the stopping check, and the outgoing drain then waits on it too. + +- **Is it a bug?** README line 399 documents "Wait … for every onSms handler still running", so it is by design. The `sendResp` docstring ("for a handler that keeps working after the answer") invites exactly this expectation. +- **Right file and unit:** `incoming-requests.ts`, `handOver`, together with `running-handlers.ts`. +- **What slows the hunt:** the reported error, "message(s) unanswered", is false for this peer and costs an extra detour into `sms.ts`. + +**Where it rots first:** `IncomingRequests.onMessage` / `handOver`. Every new inbound rule lands there (per-PDU rate limiting, the store for half-reassembled messages, new `data_sm` semantics), and each one adds another `port.closed()` check and another answer path. + +**Where the next two features would land** +- **Goal 9's store** would land across `Reassembler`, `DlrMerger` and `ExpiringGroups`. `ExpiringGroups` is the obvious seam, but it is shared with the handler counter, which must not be persisted. Separate them first. +- **A per-PDU rate limit (goal 7)** would land in `OutgoingRequests.request` beside `SendWindow`. That is a clean place. The inbound side would land in `IncomingRequests` again. + +## Hardest places, ranked + +1. `src/incoming-requests.ts:260-326`, `IncomingRequests.onMessage` / `handOver`. Bound refusal, reassembly, answer-on-arrival, the handler run, and refusal-or-loss are interleaved with `closed()` checks and `sms.sendResp` side effects. +2. `src/reassembly.ts:179-198`, `Reassembler.weighed` / `dropped`. The transient `weighing` field is read inside an `onDrop` callback to discount the newest segment. That is action at a distance through a callback. +3. `src/session.ts:291-310` with `incoming-requests.ts:146` and `running-handlers.ts:50-75`, `Session.drain`. It orders handlers, then requests, on a shared deadline (`timeout` for the first, `leftOf(deadline)` for the second), then checks `closed`. The meaning of "unanswered" is wrong. +4. `src/expiring-groups.ts:111-122`, `ExpiringGroups.expire`. It fires `onDrop` mid-iteration. `RunningHandlers.onDrop` → `settle()` → `size` → `expire()` re-enters it. +5. `src/reconnect-loop.ts:64-153` with `client.ts:143-156`, `ReconnectLoop.adopt` / `attempt` / `down` / `stop` and the client `send` retry loop on `LinkLostError`. The state lives in `session`, `timer`, `attempting`, `stopped`, `upAt` and `links`. +6. `src/pdu.ts:84-136`, `resolveShortMessage` / `resolveBody`. The rules for which of `short_message` or `message_payload` owns `data_coding`. +7. `src/sms.ts:126-231`, `sendResp` / `sendDlr` over the shared mutable `Answer` object. + +**The unit I would least want to modify:** `IncomingRequests`, `src/incoming-requests.ts:87-358`. + +## Scores + +Intrinsic difficulty, which earns no bonus: moderately high. It is an async request/response protocol with reassembly, reconnect, a drain and two ends of the link. + +- **Navigation 7.** Sits at "Predictable". File names map to concepts well enough that the 3am path took three hops. It is held below 8 by the flat 37-file `src/` and by the drain's "unanswered" error text pointing at `sendResp`. +- **Locality 6.** Between 5 and 7. The narrow ports (`SessionPort`, `SmsDeps`) keep collaborators apart. Hidden coupling holds it down: `RunningHandlers` evicting live handlers unless `refusedAtBound` runs first, the reassembler's `weighing` side channel, and the drain's ordering and shared deadline. +- **Shape 6.** Between 5 and 7. Units are small and mostly honest. Held down by the flat `src/` with no visible areas, `session-options.ts` as a grab bag, concatenation spread over three files, and the overloaded refuse/end/answer/closed vocabulary. +- **Self-sufficiency 7.** Sits at "Predictable". Most units state their invariant and cite the SMPP section at the site (`message.ts:13`, `pdu.ts:166`, `sms-id.ts:58`). Held below 8 because the area map and the import direction exist only in the AGENTS.md architecture block, not in the layout. +- **Overall 6.** Capped at 7 by the lowest dimension plus one. It sits at 6 because the unit that grows, `IncomingRequests`, is the hardest one to change safely. + +SCORES nav=7 loc=6 shape=6 self=7 overall=6 diff --git a/docs/comprehension-rewrite/plan-1.md b/docs/comprehension-rewrite/plan-1.md new file mode 100644 index 0000000..482c5f7 --- /dev/null +++ b/docs/comprehension-rewrite/plan-1.md @@ -0,0 +1,287 @@ +# Plan 1: the application developer's mental model as the source tree + +Lens: an application developer thinks in six verbs: *connect* (client), *listen* (server), *send*, +*receive*, *get a report*, *shut down*, plus *read PDUs* when they go low-level. Every directory is +one of those verbs, in the README's order, and every boundary between them is a seam a developer +already knows exists. + +## 1. Principles + +1. **The README's table of contents is the directory listing.** `Send an SMS` → `sending/`, + `Delivery reports` → `receipts/`, `Receive SMS` → `receiving/`, `Run an SMPP server` → `server/`, + `Client options`/reconnect → `client/`, `Session` → `session/`, `PDUs and the low-level API` → + `wire/`, `Encoding`/long messages → `text/`. Answers: Navigation stuck at 6 in every round, while + C, the one draft with grouped folders, reached Locality 6 on every seat. +2. **One socket, one `Session`, one life.** A `Session` goes `open → bound → closing → closed` and + never backwards. Reconnect is a separate `ClientSession` above it, whose state is a discriminated + union holding a `Session` only while one is up. Answers lesson 3: `LinkLife`'s phase plus + `stopped`, seven predicates, call-order-dependent `linkLost`/`end`/`dropSocket`, and + `ReconnectLoop`'s duplicate stopped flag. F showed that the split removes the unit; this plan keeps + F's split and fixes what F left, below. +3. **Every invariant has one owner, and that owner's file states it.** "Every inbound request gets + exactly one answer" → `session/owed-answer.ts`. "Nothing held without a bound" → + `limits/bounded-store.ts`. "A send that never reached a socket may go out on the next one" → + `client/next-link.ts`. Answers round three's "enforced jointly by `IncomingRequests` and + `sms.ts`", and D's "answered in three places". +4. **Lower areas never call up; they declare the port they need.** `receiving/`, `receipts/` and + `sending/` export functions and classes that take a narrow port type *declared in their own + file* (`AnswerPort`, `SendPort`, `report(err)`). `Session` implements the ports. Answers finding 4: + `IncomingRequests`/`HeldMessages` calling `emit`, `sendReturn`, `close` and `listenerCount` on + `Session`. +5. **A store enforces its own bound.** `BoundedStore` evicts, expires and weighs by itself, never + evicts the entry being admitted, keeps reads pure, and reports every removal through one + `onRemoved(key, value, reason)`. Answers round three's most-cited unit: `ExpiringGroups` plus + `Reassembler.trim`. +6. **A state change's effects sit inside the transition that causes them.** No reducer that returns + effects, and no callbacks wired from another file. Answers E, whose state machine scored no better + because each transition's meaning was split over five callbacks. +7. **Every SMPP term is glossed once, and every spec citation carries its half-line summary.** + `SMPP 3.4 5.2.12 (esm_class: message type and mode bits)`, never a bare `5.2.12`. The README gets + a glossary table (ESME, SMSC/MC, PDU, bind, esm_class, data_coding, UDH, sar_*, TLV, receipt, + segment). Answers lesson: juniors score Self-sufficiency 5 because of bare citations and + `data_coding` bit masks. +8. **Every default in one file.** `src/defaults.ts`, grouped by the verb that uses them. Answers + "defaults spread over several files". +9. **Hard parts are marked by one convention.** A hard part opens with an `Invariant:` paragraph at + the code it guards, which the comment rules permit, and AGENTS.md gets a "Where it is hard" list + naming the file of each. The panel's "7" asks for hard parts that are few, localized and marked. + +## 2. Layout + +Dependencies point downward in this order: `wire` ← `text` ← `limits` ← {`sending`, `receiving`, +`receipts`} ← `session` ← {`client`, `server`} ← `index.ts`. Root files are importable by all. +The only ways up are ports: types declared low, implemented by `session/`. + +``` +src/ fan-out 13: 4 files, 9 areas + index.ts Public surface, named exports only. Grouped by the README's sections. + defaults.ts Every default value, grouped by client/server/session/stores. No logic. + log.ts SmppLog, silentLog, guardedLog. + result.ts Result, VoidResult, errorFrom(), namedValue(), UnansweredError. + + wire/ "PDUs and the low-level API". Fan-out 6 + tables/ + pdu.ts pduToObj/objToPdu/pduReturn/isCommand/isResp. Stateless, total. + framer.ts PduFramer: byte stream → complete PDUs. State: the partial buffer. + refusal.ts PduRefusedError, PduHeader, the status SMPP names for each refusal. + retained-pdu.ts detach() and retainedOctets(): what holding a PDU costs. + smpp-time.ts smppDate/smppTime: the 16-char time format, absolute and relative. + tables/ The spec, as data. Fan-out 7. Knows nothing above it but result.ts. + commands.ts 33 commands, ids, params in WIRE ORDER (invariant stated at the top). + constants.ts consts, constsById, interface versions. + alphabets.ts GSM 03.38 (named gsm7 internally; 'ASCII' only as the public name), LATIN1, UCS2 codecs; detection. + data-coding.ts data_coding → alphabet and message class; one row per coding group with its meaning in words. + errors.ts ESME_* by numeric id. + tlvs.ts TLV table by numeric id; typed read/write of a TLV stream. + types.ts Wire types int8…cstring, arrays. + index.ts defs, the grouped tables. + + text/ "Encoding" and "Long messages". Fan-out 3. Pure functions. + message-text.ts encode/decode/bitCount/unencodable; where a PDU's body is (short_message vs message_payload). + splitting.ts splitMessage and segment budgets (153 GSM unpacked / 134 / 67); the "GSM is sent unpacked" note lives here. + concatenation.ts Both spellings, both directions: UDH build/parse, sar_* read, concatOf(), ConcatReference counter. + + limits/ What is bounded, and waiting on it. Fan-out 3. + bounded-store.ts BoundedStore: count cap, weight cap, expiry, one sweep timer, onRemoved(key, v, reason). Invariant: admit never evicts its own key; get() never mutates. + send-window.ts SendWindow: the maxOutstanding semaphore; idle(timeout) for the drain. + idle-waiters.ts Waiting for a count to reach zero within a budget. + + sending/ "Send an SMS". Fan-out 2. + compose-submit.ts submit_sm params from SendSmsOptions: TON, messaging mode, flash, times, the alphabet check. Pure. + sms-sender.ts SmsSender: split, send every segment together through a SendPort, collect ids, tell the merger. State: ConcatReference, ReceiptMerger. + + receipts/ "Delivery reports". Fan-out 5. + receipt-states.ts message_state ↔ stat: codes, FAILED/DELIVERD aliases, which states are transient. Invariant: only ENROUTE/SCHEDULED are not final. + read-receipt.ts dlrFromPdu(), parseReceipt(): esm_class, then TLV, then body. + write-receipt.ts The deliver_sm a sendDlr() sends: text, TLVs, esm_class 0x04 vs 0x20. Pure. + receipt-merger.ts ReceiptMerger: per-segment receipts into one MessageDlr. State: open groups, spent bases (BoundedStore x2). `close` renamed `spend`. + message-ids.ts uuidv7(), -, smsIdFormat normalisation, which response carries an id. + + receiving/ "Receive SMS" and "Receiving in depth". Fan-out 4. + receive-message.ts The one path an inbound message takes: bound check → concat? → reassemble → answer segment → hand whole message to handlers. Takes AnswerPort per request. + reassembler.ts Reassembler: incomplete groups in a BoundedStore; lost groups reported once. + running-handlers.ts RunningHandlers: onSms calls in flight. State: count, weight, deadline per call. Invariant: return releases, throw refuses with the retry status, no handler refuses at once. Its SendPort lets a running handler's sendDlr pass the drain. + sms.ts The Sms handle: fields decoded once; sendResp → the message's OwedAnswers; sendDlr → write-receipt via SendPort. + + session/ "Session". One socket's life. Fan-out 8. + session.ts Session (public): state 'open'|'bound'|'closing'|'closed' in one field; bound(), send, sendSms, sendReturn, close, unbind; emits. Each transition method carries its own effects. + session-options.ts SessionOptions type and its checks. + transport.ts One socket + framer + write(). No attach(): a socket never changes. + heartbeat.ts enquire_link on quiet, idle timeout. State: two timers. + requests.ts Outgoing: seqNr, pending map, response timeout, window slot, abort, UnansweredError. Merges pending-requests + outgoing-requests minus link logic. + dispatch.ts An inbound PDU's route: response → requests; request → onRequest hook → bind direction → enquire_link/unbind/re-bind/unknown/message/receipt. + owed-answer.ts OwedAnswer: created for every inbound request at dispatch; the only writer of a response. State: owed|answered|lost. Implements AnswerPort. + bind-direction.ts What each bind type carries per link end; data_sm stands in for submit_sm or deliver_sm; checkedBind(). + shutdown.ts The drain: handlers first, then requests, one deadline, the err naming what was lost. + + client/ "Client options" and reconnect. Fan-out 5. + client.ts client(), ClientSession (public): state {kind:'connecting'} | {kind:'up', session} | {kind:'down', attempt} | {kind:'closed'}. Forwards events of the current Session; owns SmsSender so merges survive a reconnect. + client-options.ts ClientOptions and their checks (connectTimeout, reconnect spelling, fromStart). + connect.ts Open a socket: TCP or TLS, connectTimeout over both. + bind.ts bind_* params, the bind response → session.bound(). + reconnect.ts Backoff as a pure function (delay, upSince) → next delay, and the retry timer. No stopped flag: the ClientSession's 'closed' state is the stop. + next-link.ts Sends waiting for a bound Session, one budget each; a send that never reached a socket retries here. Invariant: nothing that reached a socket is resent. + + server/ "Run an SMPP server" and "Server in depth". Fan-out 3. + server.ts server(), SmppServer: listener, sessions set, close() = stop listening then drain each. + server-options.ts ServerOptions and their checks. + accept-bind.ts Pre-bind requests, authenticate, bind_resp with sc_interface_version, Session.bound(). +``` + +Deepest level is 3 (`src/wire/tables/`). Maximum fan-out 13 at `src/`, otherwise ≤ 8. + +## 3. Public API changes + +Three changes; everything else in the README keeps its spelling, including `client()` resolving +`{ err, session }`, so every send and receipt example survives untouched. + +1. **Inbound messages reach an `onSms` handler option instead of an `sms` event.** + - Old: `session.on('sms', async sms => { await sms.sendResp(); })`, and a server's + `smpp.on('session', s => s.on('sms', …))`. + - New: `client({ onSms })`, `server({ onSms })`, `new Session({ onSms })`, typed + `(sms: Sms) => Promise | void`. `sms.sendResp()` stays the only way to answer, with the same + options. Returning releases the message, answering `ESME_ROK` first if `sendResp()` was not called. + Throwing or rejecting refuses it with the retry status (`ESME_RTHROTTLED` or `ESME_RX_T_APPN`) + unless it is already answered, and reports it on `sessionError`. With no `onSms`, every message is + refused at once with that retry status and one `warn` is logged. `sendDlr()` before the answer + returns `err`. `sms.answeredOnArrival` stays. + - Removes: the held-message timing contract (lesson 1): listener counts, the `setImmediate` turn, + `captureRejections` routed through a `WeakMap`, and "answered" living in three places, because + `answeredOnArrival` becomes a getter over the OwedAnswers. + - Serves goal 2 (a crash before the answer leaves the peer to resend, unlike C) and goal 5. The + no-handler refusal is a judgement call that goes in docs/decisions.md, resting on goal 2's "work + the peer has no reason to send again is not dropped". + - Migration is one line per listener: the handler body is unchanged. +2. **A `Session` is one socket; reconnect is `ClientSession`, which `client()` returns.** + - Old: `Session` with a `reconnect: { connect, onConnected }` option, `disconnected` and + `reconnected` events, and `sock` swapped under it. + - New: `Session` has no `reconnect` option, no `disconnected`/`reconnected`, and a fixed `sock`. + `client()` still resolves `{ err, session }`, where `session` is a `ClientSession` with today's + client methods (`sendSms`, `send`, `unbind`, `close`, `boundAs`, `peerInterfaceVersion`, + `acceptsOptionalParams()`, `bindAllows()`) and events (`close`, `disconnected`, `reconnected`, + `dlr`, `messageDlr`, `sessionError`, plus `data`/`incomingPdu`/`incomingPduObj` forwarded from + the current link). `session.sock` and `session.sendReturn()` move to `session.link`, the current + `Session` or `undefined` while down, because both belong to one socket. A hand-wired ESME that + wants reconnect builds a new `Session` per socket. + - Removes: `LinkLife` and its predicates, the call-order hazard, `ReconnectLoop`'s duplicate stop, + and client.ts's `bindOn` depending on another file's ordering (lesson 3). + - Serves goal 8 (a smaller surface, a stated scope for `Session`) and goal 4 (one stop state, so no + path rebinds after close). +3. **`linkEnd` becomes a readonly constructor option on `Session`** (old: a writable field + `session.linkEnd = 'smsc'`). A field mutable after dispatch starts is a hidden state a reader has + to chase through `bind-direction.ts`. Serves goal 4, since the bind direction decides what is + refused. + +Kept deliberately: `encoding: 'ASCII'` as the public name of GSM 03.38 (inherited from 0.4.0, +MIGRATION.md relies on it). Internally the alphabet is `gsm7` everywhere, and `alphabets.ts` glosses +the public name once. `sessionError`'s kinds stay told apart by type and message: no panel cited them. + +## 4. Where each thing goes + +| Now | New home | +| --- | --- | +| client.ts | client/client.ts (client(), fromStart), client/connect.ts (openSocket, connectTimeout), client/bind.ts | +| server.ts | server/server.ts, server/accept-bind.ts (authenticate, pre-bind, bind_resp) | +| session.ts | session/session.ts (state, API, emit guard); drain → session/shutdown.ts; dispatch/refuse → session/dispatch.ts | +| sms.ts | receiving/sms.ts (handle, sendResp); receipt building → receipts/write-receipt.ts | +| concat.ts, udh.ts | text/concatenation.ts | +| dlr.ts | receipts/read-receipt.ts, receipts/receipt-states.ts | +| dlr-merger.ts | receipts/receipt-merger.ts (`close` → `spend`) | +| error-from.ts, unanswered-error.ts, result.ts | result.ts | +| expiring-groups.ts | limits/bounded-store.ts (enforcing its own caps) | +| held-messages.ts | receiving/running-handlers.ts | +| idle-waiters.ts, send-window.ts | limits/ | +| incoming-requests.ts | session/dispatch.ts (routing, bind direction, unbind, unknown) + receiving/receive-message.ts (message path, store bound) | +| link-life.ts | deleted: the state goes to session.ts's one field; waiting for a link goes to client/next-link.ts | +| link-timers.ts | session/heartbeat.ts | +| log.ts, result.ts | root | +| message.ts | text/message-text.ts, text/splitting.ts; smppDate/smppTime → wire/smpp-time.ts | +| message-body.ts | text/message-text.ts | +| outgoing-requests.ts, pending-requests.ts | session/requests.ts; the next-link retry → client/next-link.ts | +| pdu.ts, pdu-framer.ts, pdu-refusal.ts, retained-pdu.ts | wire/ | +| pdu-transport.ts | session/transport.ts, without attach() | +| reassembly.ts | receiving/reassembler.ts; decodeSegments → text/message-text.ts | +| reconnect-loop.ts | client/reconnect.ts | +| send-sms.ts | sending/compose-submit.ts + sending/sms-sender.ts | +| session-options.ts | defaults → defaults.ts; checks → each area's *-options.ts; bindCarries/standsInFor/checkedBind → session/bind-direction.ts | +| sms-id.ts, uuid.ts | receipts/message-ids.ts | +| defs/* | wire/tables/*; encodings.ts split into alphabets.ts and data-coding.ts | + +Named-hard responsibilities: + +| Responsibility | Home and owner | +| --- | --- | +| Held messages and answering | session/owed-answer.ts (one answer per request); receiving/running-handlers.ts (the handler's life) | +| Lifecycle and reconnect | session/session.ts (one-way state); client/client.ts (union state), client/reconnect.ts (backoff) | +| The drain | session/shutdown.ts, one function; a running handler's sends admitted through its own SendPort | +| Outgoing requests and retry | session/requests.ts (one link, no retry); client/next-link.ts (the only retry) | +| Reassembly and ExpiringGroups | receiving/reassembler.ts over limits/bounded-store.ts | +| Receipts and merging | receipts/ (read, states, write, merger); merger owned by SmsSender, which ClientSession holds across links | +| The codec | wire/pdu.ts over wire/tables/ | +| Encodings | wire/tables/alphabets.ts, wire/tables/data-coding.ts; text/ above them | +| Defaults | src/defaults.ts | +| Domain knowledge and glossary | README glossary table; summarised citations; the defect table and "GSM is sent unpacked" stay in AGENTS.md, with a pointer line in splitting.ts | + +## 5. The hard parts that stay hard + +Each is marked with an `Invariant:` paragraph in its file and listed under "Where it is hard" in +AGENTS.md. + +1. **Exactly one answer per inbound request** (session/owed-answer.ts). The hardness is SMPP's: a + multipart message is answered per segment on arrival, a single one when the application says, a + refused PDU from its header alone, and never on a link that is gone. Localized, since OwedAnswer + is the only writer, and a test asserts no other file calls `transport.write` with a response. +2. **The drain's order and budgets** (session/shutdown.ts). Handlers first, because a handler's + answer can put a receipt on the wire. `shutdownTimeout: 0` still bounds the handler half. One + function, about 40 lines, with its budget rule in its signature. +3. **Retry only what never reached the socket** (client/next-link.ts). Goal 2's "never re-sent on + the library's own initiative". The rule is one predicate over the `requests.ts` result + (`written: false`), and the loop lives only here. +4. **Bounded stores and eviction order** (limits/bounded-store.ts). The reassembly weight rule, 1000 + plus octets plus 300 per TLV, stays in receiving/reassembler.ts as one function beside its + README-facing constant. +5. **data_coding coding groups** (wire/tables/data-coding.ts). Bit masks are unavoidable. One table + row per group states in words what the group means and whether it carries a class. +6. **Receipt classification** (receipts/read-receipt.ts). esm_class, then TLV, then text, with + operator aliases. It is operator folklore, so each alias names the operator in one line. + +## 6. Build order + +Each chunk keeps the suite green. Tests move to the new API only in chunks 5 and 6, the two +contract changes. + +1. **Tables and codec.** Move defs/ to wire/tables/, split encodings, rename gsm7 internally, and + move pdu, framer, refusal, retained and time into wire/. Add summaries to every citation. +2. **text/ and receipts/.** Pure moves plus `spend`. message-ids absorbs uuid. +3. **limits/.** BoundedStore replaces ExpiringGroups, with its own tests for "admit never evicts + itself" and "reads are pure". Reassembler, merger and held messages port onto it. +4. **defaults.ts, the *-options.ts files, sending/.** SmsSender with its SendPort. +5. **Contract 1: onSms.** Add owed-answer.ts, receiving/, and session/dispatch.ts. Delete + held-messages and incoming-requests. Port the `sms` listeners in tests and README, and add the + decision record for the no-handler refusal. +6. **Contract 2: one-socket Session, ClientSession.** Session gets its one-way state, plus + transport without attach, requests, heartbeat and shutdown. The client gets client/, + next-link and reconnect. Delete link-life. Port the reconnect tests to ClientSession, and make + `linkEnd` an option. +7. **server/** split, index.ts regrouped by README section, the README glossary, AGENTS.md + architecture, and "Where it is hard". +8. **Run the comprehension panel.** Fix only what it names inside the hard-parts list. + +## 7. Predicted panel risks + +- **ClientSession forwarding** (client/client.ts). It re-emits the current Session's events and + answers `boundAs` through the gap. A reader asks "which object do I listen on?" Mitigation: README + Events table gains one column, `Session` / `ClientSession`. Likely Shape 6 on the senior's seat if + the forwarding list is long. +- **Two SmsSenders in client mode.** A Session inside a ClientSession has its own unused sender, and + the ClientSession's merger is fed from the link's `dlr`. Alternative: Session takes an optional + injected SmsSender. Pick one at chunk 6 and state it in client.ts's invariant. +- **Ports feel like indirection to the junior.** `AnswerPort`/`SendPort` add names. Mitigation: each + port is 1–3 members and declared in the file that uses it. +- **Root fan-out of 13, and `limits/` is a new abstraction name.** A mid may look for the send window + under session/. It is cross-referenced from requests.ts's constructor argument only. +- **The no-handler refusal** surprises a transceiver client that never expected MO traffic: its SMSC + retries forever. That is a goal-2-correct outcome, but a reader may argue it; the decision record + must carry the argument. +- **The coarse scale.** Removing a unit has moved the hardest unit elsewhere every round. The most + likely next candidate is `owed-answer.ts` + `receive-message.ts`, which could hold a mean at 6.5 + rather than 7. The mitigation is to keep it the only writer and test that. diff --git a/docs/comprehension-rewrite/plan-2.md b/docs/comprehension-rewrite/plan-2.md new file mode 100644 index 0000000..6aeb958 --- /dev/null +++ b/docs/comprehension-rewrite/plan-2.md @@ -0,0 +1,287 @@ +# Plan 2: state ownership first + +Every piece of mutable state has one owner. The owner is the only writer, states its invariant in one +paragraph above the class, and enforces it in the same file. Other files read state through the +owner's methods, or learn about it from a result the owner returns. Nothing reaches back up. + +## 1. Principles + +1. **One object per socket, never reused.** A `Link` is born with a socket and dies with it. Its + phase only moves forward: `binding → bound → gone`, or `binding → gone`. There is no `attach()`, no + `stopped` flag and no initial state that breaks its own rule. This answers lessons 3 and round 3: + LinkLife's 4 phases plus `stopped`, the initial `'up'`, and `linkLost`/`dropSocket`/`comeBackUp`, + whose correctness depended on call order. F showed that a one-socket unit removes the lifecycle as + the hardest spot. This plan keeps that idea behind the **current** public `Session`, so the public + rename F needed is avoided. +2. **Two state fields for the session's life, one per owner, and neither derives from the other.** + `Session.life: 'open' | 'closing' | 'ended'` belongs to `Session`. `Link.phase` belongs to the + `Link`. "Can send now" is `life === 'open' && link?.phase === 'bound'`, written once in + `Session.boundLink()`. The seven predicates of lesson 3 collapse to that one method. +3. **Stoppedness has one writer.** `Session` holds an `AbortController` called `lifetime` and aborts + it in the same statement that leaves `'open'`. The reconnect loop, the waits for a link and the + drain only read `lifetime.signal`. This removes ReconnectLoop's duplicate stopped flag (lesson 3) + and client.ts's "close() must reach stop() before its first await" comment. +4. **A transition finishes before anyone hears about it.** Each transition method writes every field + first and emits last. A listener that calls `close()` from `disconnected` or `close` finds + `life === 'ended'` and gets `{}` back. This answers lesson 3's synchronous re-entry. +5. **The inbound answer has one owner.** `link/answers.ts` is the only code that writes a response + PDU, and it writes at most one per inbound sequence number. `sendReturn()`, bind handling, refusals, + segments answered on arrival and the value an `onSms` handler returns all go through it. This + answers round 3's "exactly one answer, enforced jointly by IncomingRequests and sms.ts", and D's + "answered in three places". +6. **Receiving is a handler, answered on return** (C/D/E/F: the held-message timing contract capped + every seat). The handler's promise is the hold. There is no `setImmediate`, no listener count, and + no WeakMap back from a `captureRejections` payload. +7. **A bounded store enforces its own bounds.** `BoundedStore` checks the count, the weight and the + deadline itself. Reads never mutate. Only `admit()`, `grow()` and its own timer drop entries, and + every drop goes to one `onDrop(value, reason)` given at construction. It never drops the entry the + caller is writing: it refuses that write instead. This answers round 3's ExpiringGroups finding, + named by 4 of 8 seats. +8. **Each spec citation says what it cites, and each bit mask has a name.** `wire/fields.ts` names + every `esm_class`, `data_coding` and `registered_delivery` field, with one line each. Every + `SMPP 3.4 x.y.z` cite carries a clause saying what that section requires. The terms are defined in + `docs/glossary.md`. This answers the juniors' Self-sufficiency 5, whose cost was citations with no + summary and bare masks rather than a missing glossary. +9. **Defaults live in one table.** `src/defaults.ts` holds every default and every internal cap, with + one line of why each (lesson 4). + +## 2. Layout + +Imports point one way: `wire ← text ← receipts ← link ← session ← client, server`. `bounded-store`, +`result`, `log` and `uuid` are leaves that any area may import. `link` never imports `session`. +Instead it reports to its owner through `LinkOwner`, a typed interface of five callbacks declared in +`link/link.ts`. + +Fan-out: the root has 13 entries (6 leaf files and 7 areas). Each area has 3–11 files. The tree is +at most two levels deep below `src/`. + +``` +src/ + index.ts Public surface, named exports only. No state. + defaults.ts Every default and internal cap (client, server, session, stores), one line of why each. No state. + result.ts Result, VoidResult, errorFrom(): a thrown value turned into a result. No state. + log.ts SmppLog, silentLog, guardedLog. No state. + uuid.ts uuidv7(). State: the monotonic counter within one millisecond. + bounded-store.ts BoundedStore. State: entries, total weight, sweep timer. Invariants: count <= max and weight <= maxWeight + after every write; expired entries are gone before admit() decides; the entry being written is never + dropped; every drop goes through onDrop exactly once. + wire/ The codec: bytes <-> PduObject. Stateless apart from PduFramer. (11 files) + commands.ts The 33 commands, their ids and params in WIRE ORDER (never sorted). + constants.ts consts + constsById, the interface versions. + fields.ts Named bit fields of esm_class, data_coding and registered_delivery, one line of meaning each. + Replaces hasUdh/messageTypeOf/messageClassOf's inline masks. + errors.ts ESME_* status table, ordered by id. + tlvs.ts TLV table, ordered by id; typed read/input shapes. + tlv-stream.ts Reading and writing a TLV stream. Split out of tlvs.ts. + types.ts Integer and C-Octet String wire types. + array-types.ts dest_address and unsuccess_sme arrays. Split out of types.ts (684 lines). + pdu.ts pduToObj / objToPdu / pduReturn / isCommand / isResp. + refusal.ts PduHeader, PduRefusedError, the status SMPP names for an unreadable PDU, maxPduLength. + framer.ts PduFramer. State: the partial-PDU buffer. Invariant: yields only whole PDUs; one bad length poisons the stream. + defs.ts `defs`: every table as one group. + text/ Message bodies: alphabets, splitting, concatenation, times. Stateless. (9 files) + gsm7.ts GSM 03.38 codec, named for what it is. The public EncodingName 'ASCII' maps to it in one line in alphabets.ts. + latin1.ts, ucs2.ts One codec each. + alphabets.ts detect, unencodable, encodingByDataCoding, dataCodingByEncoding. + split.ts splitMessage, bitCount, the 153-septet/134-octet segment budget (see §5). + udh.ts UDH length and concat fields; ConcatReference. State: the 8-bit reference counter. + concat.ts concatOf(): UDH or sar_*, which spelling. + body.ts messageOctets, decodeMessage, decodeSegments: where a body is and how it reads. + smpp-time.ts smppDate, smppTime encode/decode. + receipts/ Delivery reports. (4 files) + receipt-text.ts parseReceipt, receiptCodes, the researched stat: aliases (FAILED, DELIVERD). + dlr.ts dlrFromPdu: what marks a receipt, final vs intermediate. + sms-id.ts Id notations, - segment ids, which response carries an id. + merger.ts DlrMerger. State: expected groups plus the `spent` bases (two BoundedStores). Invariant: a base + merges at most once; an intermediate report never fills a slot. `close()` is renamed `spend()`. + link/ One socket's life. Everything here dies with the socket. (9 files) + link.ts Link and LinkOwner. State: phase (binding|bound|gone), gone-reason. Composes the files below. + Invariant: the phase only moves forward, and owner.onGone fires exactly once. + transport.ts Socket plus framer. State: none beyond the socket. The only write path to the socket. + timers.ts enquire_link heartbeat and idle timeout. State: two timers. Cleared when the phase is gone. + pending.ts Sequence numbers and correlation. State: next seqNr, seqNr -> waiter. Invariant: every waiter settles + once (answered, timed out, aborted, or unanswered when the link goes). + bind.ts The bind record {as, peerVersion}, checkedBind, bindCarries, standsInFor. State: the record. Set once. + answers.ts The answer ledger. State: owed requests by seqNr. Invariant: every inbound request gets exactly one + response on this link, or none because the link went; a second answer returns err. + inbound.ts Routes each inbound request to its one answer: onRequest hook, bind direction, enquire_link, unbind, + submit/deliver/data_sm, unknown commands. Stateless apart from what it calls. + handlers.ts Running onSms handlers. State: running set (a BoundedStore, 'refuse' policy), the refusing + hysteresis flag. Invariant: at most maxHeldMessages/maxHeldOctets run at once; the drain waits for zero. + reassembly.ts Reassembler. State: incomplete groups (a BoundedStore). Invariant: a segment is answered only once + it is kept; a group lost after answering is reported as lost traffic. + session/ What the application holds: one Session over a sequence of Links. (9 files) + session.ts Session. State: life, current link, last bound link, lifetime AbortController. Owns the transitions + open -> closing -> ended, and the link handover. Public events are emitted here and nowhere else. + link-wait.ts LinkWait. State: sends waiting for a bound link. Invariant: each settles on the next bound link, its + own deadline or signal, or lifetime abort. + window.ts SendWindow (maxOutstanding). State: in-flight count, FIFO queue. Invariant: in-flight <= max. + outbound.ts The one request path: admit -> wait for a link -> slot -> write -> answer; retry only if unwritten. + UnansweredError. No state of its own. + reconnect.ts Backoff. State: delay, upAt, timer. Stopped only through the lifetime signal. + shutdown.ts The drain order and budgets as one function over (handlers, window, lifetime). No state. + sms.ts The Sms handle given to onSms: fields, sendDlr(). No state beyond the message. + send-sms.ts submitSms composition, submitSmParams. + options.ts SessionOptions, ReconnectOptions, CloseOptions, SendOptions, checkSessionOptions. + client/ (3 files) + client.ts client(): compose, fromStart. + connect.ts Open a socket: TCP/TLS, connectTimeout, abort. + bind.ts The ESME's bind_* request and what a refusal means. + server/ (3 files) + server.ts server(), SmppServer. State: the listener and the live sessions set. + listen.ts Listener creation, TLS, startup errors. + accept-bind.ts authenticate, bind response, pre-bind refusals. +docs/glossary.md ESME, SMSC/MC, PDU, bind types, esm_class, data_coding, UDH, sar_*, TLV, message_state, receipt, segment, link, session. +``` + +## 3. Public API changes + +These are the only changes. `client()` still resolves `{ err, session }`, `Session` keeps its events +(`close`, `disconnected`, `reconnected`, `dlr`, `messageDlr`, `sessionError`, `data`, `incomingPdu*`), +and the codec exports stay. + +1. **`session.on('sms')` plus `sms.sendResp()` becomes an `onSms` option.** The option is accepted by + `client()`, `server()` and `new Session()`. + - Old: `session.on('sms', async sms => { await sms.sendResp({ smsId }); })`. + - New: `onSms: sms => ({ smsId })` (sync or async). Returning nothing answers `ESME_ROK` under a + generated id. `{ status }` refuses the message. + - A handler that throws or rejects refuses the message with the retry status: `ESME_RTHROTTLED` on + a submission, `ESME_RX_T_APPN` on a delivery. The error also goes to `sessionError`. + - With no `onSms`, the same retry status goes out at once. That needs a decision in + `docs/decisions.md` resting on goal 2 ("work the peer has no reason to send again is not dropped") + and goal 4. Today no listener means no answer, and the peer's own timeout decides. + - Reason: it removes the held-message timing contract (lessons 1–2) and the `captureRejections` + WeakMap. Goals 2 and 5. +2. **`sms.sendDlr()` before the message is answered returns `err`:** "report after onSms returns". The + answer is what gives the peer the id that the receipt names. Reason: without this rule there would + be a second answering path (F kept `sendResp()` for an early answer, which is two spellings of + answering). Goal 1. +3. **`session.sendReturn()` on a request that is already answered, or on a message whose `onSms` is + still running, returns `err`.** Today it writes a second response. Reason: principle 5, since the + ledger's invariant is also the public promise. Goals 1 and 4. +4. **`sms.answeredOnArrival` stays.** For a message whose segments were answered on arrival, a + returned `smsId` or refusing `status` goes to `sessionError`, where today `sendResp()` returns it as + an `err`. + +Kept on purpose: the `Session` name, `boundAs`/`peerInterfaceVersion` surviving the reconnect gap (now +read from the last bound Link), the `sock` getter (the current or last Link's socket), and the +`reconnect.connect`/`onConnected` constructor options. F's rename to `SmppClient` did not move the +mean (6.25 = main), so its cost buys nothing a reader scores. MIGRATION.md and CHANGELOG.md record +changes 1–4. + +## 4. Where each thing goes + +| Now | New home | +| --- | --- | +| session.ts | session/session.ts (life, handover, events); the dispatch moves to link/inbound.ts, the refusal answer to link/answers.ts, the drain to session/shutdown.ts | +| link-life.ts | Split: the phase goes to Link.phase; waiting for a link goes to session/link-wait.ts; `stopped` and `retrying` go to Session.life plus lifetime; `generation()` is deleted, because a message holds its Link object | +| client.ts | client/client.ts, client/connect.ts, client/bind.ts; `bindOn`'s ordering comment is deleted (principle 3) | +| server.ts | server/server.ts, server/listen.ts, server/accept-bind.ts | +| incoming-requests.ts | link/inbound.ts (routing); throttled and refused-segment statuses go to link/handlers.ts and link/reassembly.ts; `reportLost` becomes LinkOwner.onLost | +| held-messages.ts, sms.ts (MessageHold) | link/handlers.ts (the running set and bound); session/sms.ts (the handle); answering goes to link/answers.ts | +| outgoing-requests.ts | session/outbound.ts; `requestPastDrain`/`requestOnCurrentLink` become one `request(input, { lane })`, with `lane: 'app' | 'receipt' | 'bind' | 'unbind'` and a single `admit(lane, life)` table | +| pending-requests.ts | link/pending.ts (per link, so linkLost is the Link going) | +| send-window.ts, idle-waiters.ts | session/window.ts; the idle wait is inlined there and in link/handlers.ts (the abort dance is copied, per the existing decision) | +| link-timers.ts | link/timers.ts | +| reconnect-loop.ts | session/reconnect.ts; `halted` is deleted in favour of the lifetime signal | +| pdu-transport.ts, pdu-framer.ts | link/transport.ts (no `attach`: one socket per Link), wire/framer.ts | +| pdu.ts, pdu-refusal.ts, retained-pdu.ts | wire/pdu.ts, wire/refusal.ts; `detach`/`retainedOctets` go to bounded-store's weigher callers in link/ | +| expiring-groups.ts | bounded-store.ts | +| reassembly.ts | link/reassembly.ts; `decodeSegments` goes to text/body.ts | +| dlr-merger.ts, dlr.ts, sms-id.ts | receipts/merger.ts, receipts/dlr.ts plus receipt-text.ts, receipts/sms-id.ts | +| message.ts, message-body.ts, udh.ts, concat.ts | text/split.ts plus smpp-time.ts, text/body.ts, text/udh.ts, text/concat.ts | +| send-sms.ts | session/send-sms.ts | +| session-options.ts | session/options.ts; `defaults` goes to defaults.ts; bind helpers go to link/bind.ts | +| error-from.ts, unanswered-error.ts | result.ts, session/outbound.ts | +| log.ts, result.ts, uuid.ts | unchanged at the root | +| defs/* | wire/*, with defs/encodings.ts split into text/gsm7.ts, latin1.ts, ucs2.ts and alphabets.ts, and masks into wire/fields.ts | + +The responsibilities the panels named hard: + +- **Held messages and answering:** link/answers.ts owns the one answer; link/handlers.ts owns the + running handler and its bound; link/inbound.ts calls the two in sequence. +- **Lifecycle and reconnect:** session/session.ts (life plus handover), link/link.ts (phase), + session/reconnect.ts (backoff). +- **The drain:** session/shutdown.ts. +- **Outgoing requests and retry:** session/outbound.ts (one loop), link/pending.ts (correlation), + session/window.ts, session/link-wait.ts. +- **Reassembly and ExpiringGroups:** link/reassembly.ts on bounded-store.ts. +- **Receipts and merging:** receipts/. +- **Codec:** wire/. +- **Encodings:** text/. +- **Defaults:** defaults.ts. +- **Domain knowledge:** docs/glossary.md plus wire/fields.ts. + +## 5. The hard parts that stay hard + +Each hard part has an `Invariant:` paragraph above the class or function that owns it. AGENTS.md +gains a "Hard parts" index of one line per entry, giving the file and the invariant. + +1. **Exactly one answer, with multipart answered on arrival** (link/answers.ts, inbound.ts). A relaying + SMSC waits for each segment's answer, so segments are answered before the whole message exists, and + the handler's answer then has nothing left to write. All of it is in two adjacent files; the ledger + refuses the second answer loudly. +2. **Retry only what was never written** (session/outbound.ts). This is goal 2's "never re-send what + the peer may have taken". The loop: `boundLink()`, then a slot, then `link.write()`. An `unwritten` + result from a gone Link loops back to `boundLink()`. A written request that fails is + `UnansweredError`. The loop reads no link predicates; the Link's own result says what happened. +3. **The drain** (session/shutdown.ts): handlers first (answering can emit a receipt), then the window, + under one deadline. `shutdownTimeout: 0` never makes the handler wait forever. It is one function, + with the order and each budget commented once. +4. **The link handover** (session/session.ts `adopt(link)` / `onGone(link, reason)`). This is the only + place where `current` changes. The link-wait releases on `adopt`; `disconnected` or `close` is + emitted last. +5. **Backoff reset only after a link has outlasted `maxDelay`** (session/reconnect.ts). The existing + decision is linked from there. +6. **Bounded stores under pressure** (bounded-store.ts, and each owner's drop policy). Reassembly now + refuses a segment that would force its own group out, where today it evicts that group; the peer + keeps and retries it. The eviction of older groups is reported as lost traffic through onDrop. +7. **The segment budget** (text/split.ts): 153 GSM septets unpacked versus 134 octets, as AGENTS.md + explains. +8. **data_coding and esm_class** (wire/fields.ts): coding groups and message classes, named and + glossed. + +## 6. Build order + +Each chunk is green on its own and is one PR. + +1. **Words first, with no behaviour change.** docs/glossary.md, a citation sweep, wire/fields.ts, + defaults.ts, and `gsm7`/`spend` renames. Re-run the panel cheaply on this alone to learn how much + Self-sufficiency moves. +2. **BoundedStore replaces ExpiringGroups.** Port Reassembler, DlrMerger and HeldMessages to it; tests + for the own-entry refusal. +3. **Mechanical move into wire/, text/, receipts/.** Imports only, plus the types.ts and tlvs.ts + splits. +4. **The contract.** `onSms` option, link/answers.ts, link/handlers.ts, session/sms.ts; `sms` event and + `sendResp()` removed; tests and README examples ported (draft-f's `port-session-test.py` and API map + help here); the no-handler decision recorded. +5. **Link.** link/link.ts owns the per-socket state (transport, timers, pending, bind, answers, + handlers, reassembly); Session gets `life`, `current`, `lifetime`, `adopt`/`onGone`; LinkLife is + deleted; reconnect.ts reads the signal. +6. **One request path.** session/outbound.ts with lanes, link-wait.ts, shutdown.ts. +7. **Client and server folders**, then the docs pass: README (Receive SMS, Server, Shutdown), MIGRATION, + CHANGELOG, decisions (retire LinkLife-era entries, add the onSms and ledger decisions), and the + AGENTS.md architecture and hard-parts index. +8. **Panel.** + +## 7. Predicted panel risks + +- **Session versus Link vocabulary.** A junior may not see why the public thing is a "session" and the + inner one a "link". The glossary defines both, and link/link.ts's invariant paragraph says "one + socket; a Session outlives many". It could still cost a Navigation point. +- **session.ts stays the biggest hub.** It composes link-wait, window, reconnect, merger and the + handover, so readers will rank it hardest. The mitigation is that it holds three fields and two + transition methods; the target is under 250 lines. +- **LinkOwner is a callback interface.** E's lesson was that meaning split across callbacks in another + file is hard. The mitigation is five callbacks, each with one line in link.ts, all implemented + side by side in session.ts. A seat may still call it indirection. +- **Lanes in outbound.ts.** Four lanes are still four rules, but they are in one table; round 2 cited + lanes spread over methods. +- **The sendDlr rule is a friction point** for test-double servers that want to report immediately. + The README must show the pattern (report after the handler returns), or seniors will call it a trap. +- **The public name 'ASCII'** still says ASCII for GSM 03.38. Renaming it is a breaking change this + plan does not take; alphabets.ts carries the one-line mapping. +- **The new own-entry refusal policy** is a behaviour change under pressure. It needs a test and a + decision entry, or the architect seat will flag it as unreasoned. +- **The scale is coarse.** Chunks 1–3 may lift juniors' Self-sufficiency to 6 and nothing else. The + full point needs chunks 4–5 to lift Locality to 6 in every seat, and to 7 in two. diff --git a/docs/comprehension-rewrite/plan-3.md b/docs/comprehension-rewrite/plan-3.md new file mode 100644 index 0000000..ad283a2 --- /dev/null +++ b/docs/comprehension-rewrite/plan-3.md @@ -0,0 +1,241 @@ +# Plan 3: the newcomer's lens + +A reader who has never opened the SMPP spec reads `protocol/` and learns the protocol from plain-English +types; a reader who knows it reads `session/` and holds the whole machinery at once. Builds on F (one +socket per `Session`, reconnect above it, `onSms` answered on return) and removes what F's panel still +named: `ExpiringGroups`, "exactly one answer" split over two files, and the junior's missing SMPP. + +## 1. Principles + +1. **Every octet that packs several facts is translated once, in `protocol/`, into a named plain type.** + `esm_class`, `data_coding`, `registered_delivery`, the UDH, `sar_*` and `message_state` are read and + written there and nowhere else; `session/` and `messages/` never see a bit mask. Answers: juniors at + Self-sufficiency 5 (masks, bare citations), "no glossary". +2. **The vocabulary is code.** `protocol/vocabulary.ts` holds one type per SMPP term (ESME/SMSC, + bind type, alphabet, message kind, segment, TLV, status), each with a one-line TSDoc definition the + editor shows on hover. A README glossary did not lift juniors; a definition beside the use does. +3. **A spec citation always carries its sentence.** `SMPP 3.4 §5.2.12 (esm_class): bits 5-2 say whether + this is a message or a receipt.` A test greps `src/` and fails on a bare `§x.y.z`. Answers: "citations + with no summary". +4. **A `Session` is one socket, and its life only moves forward.** `open → bound → closing → closed`, + one field, one transition function, no flag beside it. Reconnect lives above, in `SmppClient`, which + holds only what outlives a socket. Answers lesson 3 (LinkLife, seven predicates, call-order + correctness, duplicated stopped flags); F showed it takes the lifecycle off the hardest-unit list. +5. **An answer is a return value, and one function writes it.** Every inbound request resolves to one + `Reply`; `session.ts` writes it in one place. `onSms` and `onRequest` return replies instead of + calling a sender. Answers F's "exactly one answer enforced jointly by IncomingRequests and sms.ts", + and lesson 4's callbacks into `Session`. +6. **A bounded store refuses; it never evicts.** One `BoundedStore` enforces its own count, weight and + expiry; reads never mutate; the only removal a caller did not ask for is expiry, reported through + one callback. A full store refuses the newcomer, and the peer retries it (goal 2: nothing the peer + will not resend is dropped). Answers `ExpiringGroups`/`Reassembler.trim` (4 of 8 F seats). +7. **Every default is one row in one table.** `options.ts`. Answers "defaults spread over several files". +8. **Names say the domain, not the implementation.** GSM 7-bit is `gsm7` everywhere; `DlrMerger.close` + becomes `spend`; no `ascii`. + +## 2. Layout + +Areas, in reading order: `protocol/` (what SMPP means), `codec/` (bytes ↔ objects), `messages/` +(whole messages), `session/` (one socket), `client/`, `server/`. Imports point down that list in +reverse: `codec` ← `protocol` ← `messages` ← `session` ← `client`/`server`; `protocol` knows `codec`'s +tables only. Fan-out: root 10 (4 files, 6 dirs); `codec/` 8; `protocol/` 10; `messages/` 8; +`session/` 7; `client/` 3; `server/` 2. No file over ~300 lines except `codec/field-types.ts`. + +``` +src/ + index.ts Public surface, named exports only. + options.ts `defaults`: every default and internal cap, one row each with its why; the + option checks for client(), server() and new Session(). No state. + result.ts Result, VoidResult, errorFrom(), namedValue(). No state. + log.ts SmppLog, silentLog, guardedLog(). No state. + codec/ Bytes <-> PduObject. Knows field layout, never meaning. + commands.ts The 33 commands, ids, params in wire order (invariant: never sorted). + tlvs.ts TLV table by id, typed read/input shapes, read/write a TLV stream (exact to + command_length, one NULL pad tolerated). + statuses.ts command_status table (ESME_*), by id. + constants.ts Raw numeric tables (`consts`), exported as-is; meaning lives in protocol/. + field-types.ts int8/16/32, C-Octet and Octet strings, buffers, address arrays; range-checked. + pdu.ts pduToObj / objToPdu / pduReturn / isCommand / isResp. Synchronous, total. + framer.ts PduFramer: a byte stream cut into PDUs; state: the chunk list. + refusal.ts PduRefusedError, framing refusal, the status a refused PDU is answered with. + protocol/ What the fields mean. Plain types out, SMPP octets in. No state. + vocabulary.ts One type per SMPP term with its one-line definition: LinkEnd (ESME/SMSC), + BindType, Alphabet, MessageKind, Segment, MessageState, Reply. The glossary. + alphabets.ts The gsm7, latin1 and ucs2 codecs; detect(); unencodable(); bitCount(). + data-coding.ts data_coding as a table of rows {octets, alphabet, messageClass, why}; read + and write through the table, never a mask outside it. Flash lives here. + esm-class.ts esm_class -> {kind: message|receipt|intermediate, hasUdh, mode} and back. + segments.ts How a PDU says it is a segment: the UDH (walked, with its reference) or sar_*, + and the reference counter per client (state: one counter). + receipt.ts Receipt text and TLVs -> Dlr (stat: spellings, FAILED, DELIVERD, final or + not), and a receipt's deliver_sm params for a state. + message-ids.ts Id notations (smsIdFormat), - segment ids, which response carries one. + time.ts smppTime (absolute/relative) and the receipt's YYMMDDhhmm stamp. + bind.ts What a bind direction carries, data_sm's stand-in, the version that allows + optional params, checkedBind(). + arrival.ts One inbound message-carrying PDU -> Arrival: {kind:'message', text, from, to, + segment?, wantsReceipt, flash} | {kind:'receipt', dlr}. Body location + (short_message vs message_payload) decided here. + messages/ Whole messages across segments and time. + split.ts Text -> segment bodies at the 153-septet / 134-octet budget (invariant: GSM is + sent unpacked; see AGENTS.md). + submit.ts SendSmsOptions checked, then one submit_sm params object per segment; all + refusals before any segment exists. + bounded-store.ts BoundedStore: count cap, weight cap, per-entry deadline. add() returns + added|full; get() is pure; expiry on its own timer, reported via onExpire. + reassembly.ts Reassembler: segment groups in two reference spaces (udh, sar); add returns + kept|unplaceable|full|whole. State: one BoundedStore. + receipt-merge.ts ReceiptMerge: per-segment Dlrs -> one MessageDlr; a base merged once (spent). + State: two BoundedStores (open, spent). + retained.ts detach() a PDU off its chunk; retainedOctets() for weights. + session/ One socket's life. State lives in session.ts, requests-out.ts, handlers.ts. + session.ts Session: lifecycle field + transition table, the event surface, admit() for + sends, write() for replies — the only writer of responses. ~250 lines. + requests-out.ts OutgoingRequests: sequence numbers, pending map, send window, response + timeout, abort. One entry point. Reports written-or-not on failure. + requests-in.ts replyFor(pdu, context) -> {reply?, after?: 'close'}: bind direction, + enquire_link, unbind, receipts, messages, unknown commands. No state, no + callbacks into Session. + handlers.ts RunningHandlers: onSms calls in flight; count/weight bound; handlerTimeout + answers with the retry status; idle() for the drain. + keepalive.ts enquire_link on a quiet link, idle timeout. State: two timers. + transport.ts Socket -> framed, parsed PDUs; refusals; the socket is fixed for life. + waiting.ts waitUntil(predicate, budget, signal) and leftOf(): the abort dance, once. + client/ + client.ts SmppClient: current session, cross-link state (ReceiptMerge, segment + reference counter), request loop that waits for a bound session and retries + only an unwritten request; re-emits session events; close()/unbind(). + connect.ts openSocket (net/TLS, connectTimeout) and bind(); returns a bound Session. + backoff.ts Backoff: next delay, reset after a link outlasts maxDelay. No timers, no stop flag. + server/ + server.ts SmppServer, server(): listener, live sessions, close() drains all. + accept-bind.ts authenticate, record the bind, bind_resp; before-bind refusals. +``` + +## 3. Public API changes + +Six, all in the minor that ships this (pre-1.0: the minor is the breaking unit). Each lands in +MIGRATION.md; the goal it rests on is recorded in docs/decisions.md. + +| # | Old | New | Removes | Goal | +|---|---|---|---|---| +| A1 | `client()` → `{ err, session }`, one `Session` reconnecting under you | `{ err, client }`, an `SmppClient` with `sendSms/send/close/unbind`, events `close/disconnected/reconnected/dlr/messageDlr/sessionError`; `client.session` is the current bound `Session` or `undefined` | Lifecycle as the hardest unit: LinkLife, 7 predicates, re-entrant close, two stopped flags, bindOn's cross-file ordering | 5, 8 | +| A2 | `session.on('sms', sms => sms.sendResp(opts))`; drain waits on unanswered `sms` | `onSms: sms => Reply \| void` option on `client()`, `server()`, `new Session()`; return answers (`ESME_ROK`, or `{ smsId }`, or `{ status }`); throw/reject answers the retry status and reports on `sessionError`; no `onSms` answers the retry status; `sendResp()` removed | Held-message timing contract (six exits, setImmediate, listener counts, WeakMap) and "answered in three places" | 2, 5 | +| A3 | `await sms.sendResp(); await sms.sendDlr()` in the listener | `onSms` may return `{ dlr: MessageState }`: the receipt goes out right after the answer; `sms.sendDlr()` stays for later reports and returns `err` before the answer is written | The "sendDlr let past the drain straight after sendResp" rule; a receipt can no longer precede its answer, and awaiting one inside the handler cannot deadlock | 2 | +| A4 | `onRequest: (s, pdu) => boolean`, answering via `session.sendReturn()` | `onRequest: (s, pdu) => Reply \| undefined`; `undefined` = built-in handling; `sendReturn()` removed from `Session` (`pduReturn()` stays in the codec) | The second answering path; makes "one answer per request" a type | 2, 7 | +| A5 | `new Session({ reconnect, ... })`, mutable `session.linkEnd` | no `reconnect`; `linkEnd` option, readonly; `disconnected`/`reconnected`/`messageDlr` only on `SmppClient` | Reconnect state inside the one-socket unit | 8 | +| A6 | `encoding: 'ASCII'` | `encoding: 'GSM7'`; `'ASCII'` refused at option check with "use 'GSM7'" | A junior reading "ASCII" for GSM 03.38 | 1 (a wrong name invites wrong data), 8 | + +Unchanged on purpose: the codec exports, `sendSms()` options and result, `Dlr`/`MessageDlr`, `server()` +options, `sessionError`/`serverError`, `PduRefusedError`. Behaviour change without a signature change: +the reassembly and receipt-merge stores refuse at their bound instead of evicting the oldest (segment: +`ESME_RTHROTTLED`/`ESME_RX_T_APPN`; merge: that send reports through `dlr` alone). Recorded as a +decision on goal 2, which it serves better than eviction did (an evicted group is answered traffic lost). +A6 is the cheapest to drop if the board wants fewer breaks; the internal rename happens either way. + +## 4. Where each thing goes + +| Now | New home | +|---|---| +| index.ts | index.ts | +| client.ts | client/connect.ts (socket, TLS, timeout, bind), client/client.ts (fromStart, abort) | +| server.ts | server/server.ts, server/accept-bind.ts | +| session.ts | session/session.ts (lifecycle, events, write), client/client.ts (reconnect parts) | +| link-life.ts | deleted: lifecycle field in session/session.ts; "wait for a link" in client/client.ts | +| reconnect-loop.ts | client/backoff.ts (delay math) + client/client.ts (the one timer, stop = state) | +| link-timers.ts | session/keepalive.ts | +| pdu-transport.ts | session/transport.ts (no attach(): one socket) | +| outgoing-requests.ts, pending-requests.ts, send-window.ts, unanswered-error.ts | session/requests-out.ts (one entry point; UnansweredError kept, exported type unchanged) | +| incoming-requests.ts | session/requests-in.ts (returns replies) | +| held-messages.ts | deleted: session/handlers.ts counts running handlers | +| idle-waiters.ts | session/waiting.ts | +| sms.ts | session/handlers.ts builds the Sms; its fields come from protocol/arrival.ts; sendDlr params from protocol/receipt.ts | +| expiring-groups.ts | messages/bounded-store.ts | +| reassembly.ts | messages/reassembly.ts | +| dlr-merger.ts | messages/receipt-merge.ts (close → spend) | +| dlr.ts | protocol/receipt.ts | +| concat.ts, udh.ts | protocol/segments.ts | +| message-body.ts | protocol/arrival.ts | +| message.ts | messages/split.ts (split, budgets), protocol/alphabets.ts (encode/decode/bitCount), protocol/time.ts | +| send-sms.ts | messages/submit.ts | +| sms-id.ts | protocol/message-ids.ts | +| session-options.ts | options.ts (defaults, checks), protocol/bind.ts (directions, stand-in), session/session.ts (events type) | +| retained-pdu.ts | messages/retained.ts | +| pdu.ts, pdu-framer.ts, pdu-refusal.ts | codec/pdu.ts, codec/framer.ts, codec/refusal.ts | +| defs/commands, tlvs, errors, types, constants | codec/commands, tlvs, statuses, field-types, constants | +| defs/encodings.ts | protocol/alphabets.ts + protocol/data-coding.ts | +| defs/index.ts | deleted; `defs` assembled in index.ts | +| error-from.ts, result.ts | result.ts | +| log.ts, uuid.ts | log.ts; uuid.ts → protocol/message-ids.ts | + +| Hard responsibility | Home | +|---|---| +| Held messages and answering | session/requests-in.ts decides the reply; session/handlers.ts runs onSms and turns its outcome into a Reply; session/session.ts write() is the one writer | +| Lifecycle | session/session.ts: 4 states, forward only, `close` emitted on entering `closed` | +| Reconnect | client/client.ts (loop, current session), client/backoff.ts (delays) | +| The drain | session/session.ts `close()`: state → closing, `handlers.idle(budget)`, then `requests.idle(rest)`, then closed | +| Outgoing requests and retry | session/requests-out.ts (one link, no retry); client/client.ts (retry on the next link only when unwritten) | +| Reassembly, store | messages/reassembly.ts over messages/bounded-store.ts | +| Receipts and merging | protocol/receipt.ts (reading/writing), messages/receipt-merge.ts (merging), client/client.ts (owns the merge across links) | +| Codec | codec/ | +| Encodings | protocol/alphabets.ts, protocol/data-coding.ts | +| Defaults | options.ts | +| Domain knowledge, glossary | protocol/vocabulary.ts and the rest of protocol/ | + +## 5. The hard parts that stay hard + +Each is marked by one invariant paragraph at the top of the function it guards (the one comment +exception AGENTS allows), and named in AGENTS.md's architecture list as "hard". + +1. **Multipart is answered on arrival, a whole message on return** (decision: a relaying SMSC waits per + segment). One function, `requests-in.ts replyFor()`, holds both branches; `Sms.answeredOnArrival` + stays; a Reply refusing an arrival-answered message goes to `sessionError`. +2. **Segment grouping**: two reference spaces, inconsistent totals, the refusal status per spelling. + `messages/reassembly.ts` only; the store underneath is dumb. +3. **The drain's two budgets** (handlers ignore `shutdownTimeout: 0`, requests do not). + `session.ts close()`, one function, both budgets computed in one place from `options.ts`. +4. **Retry only what never reached the socket** (goal 2). `client.ts request()`, one loop, reading only + `result.written`; no link state consulted mid-await because the session it used is fixed. +5. **data_coding**: coding groups, class bits, 0x01 read as GSM. Becomes a readable table in + `protocol/data-coding.ts`, with a test that the table equals today's function on all 256 octets. +6. **Codec strictness**: TLV stream exact to `command_length`, one NULL pad, 32-bit seqNr echo. + `codec/pdu.ts` and `codec/tlvs.ts`. +7. **`fromStart` + abort**: `client/client.ts client()` only; the loop's stop is the client's state + `closed`, so no ordering promise crosses files. + +## 6. Build order + +Each chunk leaves the suite green and ships through /larv-review. + +1. **Moves only**: `defs/` → `codec/`, `options.ts` with one defaults table, `result.ts` absorbs + error-from. No behaviour change. +2. **protocol/**: vocabulary, data-coding table (256-octet equivalence test), esm-class, segments, + receipt, message-ids, time, bind, arrival; internal gsm7 rename; citation test. +3. **messages/**: bounded-store, reassembly and receipt-merge on it (refuse-not-evict, decision + recorded, README bound text updated), split, submit. +4. **session/** under the new contract: one-socket Session, requests-out, requests-in replies, + handlers, onSms/Reply/`dlr` in Reply, onRequest returning Reply (A2–A5). server/ ported. Tests + ported by the F translation table; the held-message tests become handler tests. +5. **client/**: SmppClient, connect, backoff; reconnect and `fromStart` tests re-pointed (A1). +6. **A6**, README/MIGRATION/CHANGELOG/decisions/AGENTS architecture; README examples executed; + interop suite; benchmarks against goal 6's floors. +7. **Four-seat panel run**; its findings become the next chunk. + +## 7. Predicted panel risks + +- **Two send surfaces.** `SmppClient.sendSms()` and `Session.sendSms()` look alike; a reader asks + which to call. Mitigation: the Session's is the one-link primitive, documented as such; still likely + a Navigation point. +- **`Reply` carrying `dlr`** is a second way to send a receipt beside `sms.sendDlr()`. Different + results (with the answer vs later), but a strict reader may call it two spellings. +- **Refuse-not-evict**: a peer that abandons many groups blocks new multipart for `reassemblyTimeout`. + A senior may call that an operator-facing regression (goal 4) and score Shape down. +- **protocol/ vs requests-in.ts**: "is it a receipt" (arrival.ts) and "what do we answer" + (requests-in.ts) are split by design; a junior may look for both in one place. +- **codec/field-types.ts** stays ~650 dense lines; it was never the named unit, but a junior reading + it cold still scores Self-sufficiency down unless its citations carry their sentences too. +- **Test suite size**: porting ~all session tests is the real cost; a half-ported suite hides + regressions that a panel will not see but goal 1 will. +- **The coarse scale**: even if every named unit is fixed, a mean of 7.0 needs all four seats to move, + and the hardest-unit list has moved every round; expect a new one (likely requests-in.ts or + SmppClient's request loop) at 6. diff --git a/todo.md b/todo.md index 3e5b9c7..d050791 100644 --- a/todo.md +++ b/todo.md @@ -197,22 +197,58 @@ A four-seat scoring run on 2026-09-27 read #30 at 6, 6, 7 and 6, every seat capp the held-message and shutdown code; #30 merged under the floor on condition that Locality is the next work ([decision](docs/decisions.md#internals-and-tests)). -### Locality — next, ahead of everything below; 5–6 today, and the gate is 7 +### Locality — the plan-3 rewrite, next and ahead of everything below; 6.25 today, and the gate is 7 -A second four-seat run on 2026-09-28, after #35–#40, read 6, 6, 7 and 6 again, Locality 5, 5, 6 -and 5. Every seat ranked the session's lifecycle hardest and least wanted to modify it. A third, -after #46, read 6, 6, 7 and 7, Locality 5, 5, 6 and 6. A fourth, after the link's liveness got one -owner in #48, read 6, 6, 6 and 6, Locality 5 from every seat: all four still ranked `Session.teardown()` -hardest, and the held-message flow across `incoming-requests.ts`, `held-messages.ts`, `sms.ts` and -`Session`'s rejection handler second. -A fifth, after the held-message flow got one owner, read 6, 6, 7 and 6, Locality 5, 5, 6 and 6: -three seats still ranked `MessageHold` hardest — six ways out, a rejection routed from `Session` -through `IncomingRequests` to a `WeakMap`, and the `setImmediate` a receipt relies on — and the -teardown cluster second; the inherited architect scored Shape 5 on the flat `src/` and the names -below. +The scoring run after #49 read 6, 6, 7 and 6, Locality 5, 5, 6 and 6. Three rounds of redesign +drafts (A–F) each took the hardest unit off the panel's list and exposed the next, and none moved the +mean past 6.25. A four-seat board then read three from-scratch plans. It ranked plan 3 first +unanimously and predicted 6, 7, 7 and 7, and every seat judged the ceiling to be the code's structure, +not SMPP's difficulty. Maintainer's call, 2026-09-30: build plan 3. The background is in +[docs/comprehension-rewrite/](docs/comprehension-rewrite/): the plans, the board, every panel report, +the lessons and the six drafts as patches. Delete that directory once this milestone ships. -- [ ] **Lift Locality to 7, and confirm it with a scoring run.** A run reading 7.0 or above also - retires the Locality-first decision. +Every chunk below ships through `/larv-review`, and before it merges it also runs the +comprehension-panel scoring run over the whole project. Each round: + +- fix what the seats name inside the chunk's own area; +- ask the seats what would lift the bar further, and file what lies outside the chunk here; +- let no dimension drop, and record the four scores and the overall in this paragraph. + +Each chunk also closes the items further down that it absorbs, and its PR names them: `hold` and +`refusing`, the three `defaults`, `ASCII`, `session-options.ts`, the flat `src/`, and the link that +dropped mid-rebind. + +- [ ] **Run the architecture review on plan 3 as amended here, before chunk 1.** Put its findings + into the plan, and ask where they change a public API choice. +- [ ] **Move without changing behaviour.** `defs/` becomes `codec/`, `options.ts` becomes one defaults + table, and `result.ts` absorbs `error-from.ts`. +- [ ] **Build `protocol/`: every multi-fact octet read once into a named plain type.** + - `vocabulary.ts`: the glossary as types with one-line TSDoc. + - `data-coding.ts`: the `data_coding` table as rows, with a test that it equals today's function + on all 256 octets. + - `esm_class`, segments, receipts, message ids, time and bind. + - The internal `gsm7` rename. + - A test that fails on a spec citation without its sentence. +- [ ] **Build `messages/` on a `BoundedStore` that enforces its own bounds and refuses instead of + evicting.** Reassembly and receipt merging move onto it. Record the refuse-not-evict decision + against goals 2 and 4, and update README's bound text. +- [ ] **Make a `Session` one socket under the `onSms` contract (plan 3's A2, A4 and A5).** + - Its life moves only forward. + - Every inbound request resolves to one `Reply`, written by one function. + - `onRequest` returns a `Reply`, and `server/` is ported. + - The board advises dropping A3 (a receipt state returned from `onSms`) as a second way to send a + receipt; ask before building it. +- [ ] **Put reconnect above the session in `client/` (A1).** + - `SmppClient`, `connect` and backoff. + - The unanimous board amendment: the wait for a bound session and the retry of only what never + reached the socket get their own `client/next-link.ts`, invariant at the top, so `client.ts` + only composes. + - The board rated A1's `{ err, client }` rename worth questioning; ask before shipping it. +- [ ] **Finish the contract (A6) and the prose.** A6 renames `'ASCII'` to `'GSM7'`; the plan names it + the cheapest break to drop, so ask. Then README, MIGRATION.md, CHANGELOG.md, decisions and the + AGENTS.md map; the interop suite; and the benchmarks against goal 6's floors. +- [ ] **Confirm Locality at 7 with a final scoring run.** A run reading 7.0 or above retires the + Locality-first decision. ### Correctness