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