383 lines
29 KiB
Markdown
383 lines
29 KiB
Markdown
# AGENTS.md
|
|
|
|
Guidance for LLM agents working in this repository. Human-facing documentation lives in
|
|
[README.md](README.md); the remaining work is tracked in [todo.md](todo.md).
|
|
|
|
## What this is
|
|
|
|
A ground-up TypeScript rewrite of `larvitsmpp` 0.4.0, published as `@larvit/smpp` 1.0.0. The branch
|
|
started from an orphan commit — no history from 0.4.0 is carried over. The 0.4.0 source is still
|
|
readable on the `master` branch of the same repository and is the reference for protocol behaviour,
|
|
not for structure or style.
|
|
|
|
The library's value is its very small API. Do not grow the public surface without being asked.
|
|
|
|
## Hard rules
|
|
|
|
These are not preferences. Breaking one is a defect.
|
|
|
|
1. **Nothing throws.** Every fallible function returns (or resolves to) a DTO carrying an optional
|
|
`err`. No `throw`, no rejected promises, no exceptions as control flow. Node APIs that throw are
|
|
wrapped at the boundary and converted into a result. Programmer errors (bad arguments) are
|
|
results too.
|
|
2. **Log messages are static strings.** Every dynamic value goes into the log metadata. Never
|
|
interpolate, never concatenate.
|
|
- GOOD: `log.debug('sendSms() - splitting message', { parts: msgs.length, to });`
|
|
- BANNED: `log.debug('sendSms() - splitting into ' + msgs.length + ' parts');`
|
|
3. **No `error` event.** Node makes an unhandled `error` event throw, which would break rule 1.
|
|
Sessions emit `sessionError`, servers emit `serverError`.
|
|
4. **No casts, no non-null assertions.** `as`, `as unknown as` and `!` are all banned. Parse untyped
|
|
input once through a type guard at the boundary; everything past it is typed. `noUncheckedIndexedAccess`
|
|
is on, so every lookup into a record or buffer is `T | undefined` until you handle it — that is the
|
|
point, not an obstacle to route around.
|
|
|
|
## Architecture
|
|
|
|
```
|
|
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: dispatch, events, and the collaborators below
|
|
sms.ts The live handle emitted as the 'sms' event (sendResp/sendDlr)
|
|
dlr.ts Delivery receipts: text and TLV parsing, receipt status codes
|
|
dlr-merger.ts DlrMerger: per-segment receipts counted into one MessageDlr
|
|
error-from.ts errorFrom(): whatever was thrown or rejected, as an Error
|
|
expiring-groups.ts ExpiringGroups: the capped, expiring store both of those share
|
|
incoming-requests.ts Every request the peer sends: messages, receipts, links, unknown commands
|
|
link-gate.ts LinkGate: where a request with no link to go out on waits for the next one
|
|
link-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
|
|
pdu.ts pduToObj / objToPdu / pduReturn — synchronous, result-returning
|
|
pdu-framer.ts PduFramer: a byte stream cut into complete PDUs
|
|
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<T> — the shape every fallible call returns
|
|
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 The notation a peer writes message ids in, normalised for comparison
|
|
udh.ts User data header: the concatenation fields of a long SMS
|
|
uuid.ts uuidv7() — the ids the library generates for messages
|
|
defs/
|
|
commands.ts The 33 commands, their ids and ordered parameter lists
|
|
constants.ts consts + constsById, and the SMPP version constants
|
|
encodings.ts GSM 03.38, LATIN1, UCS2, detection, data_coding resolution
|
|
errors.ts errors + errorsById (ESME_*)
|
|
tlvs.ts TLV definitions, tlvsById
|
|
types.ts Wire types: int8/int16/int32/string/cstring/buffer/arrays
|
|
```
|
|
|
|
Dependency direction is one way: `defs` knows nothing above it, `pdu` uses `defs`, `session` uses
|
|
`pdu`, and `client`/`server` use `session`. Nothing reaches back up.
|
|
|
|
**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
|
|
convention applies everywhere else, but here it corrupts every PDU.
|
|
|
|
## Toolchain
|
|
|
|
Run everything through the container; never invoke node or npm on the host.
|
|
|
|
```bash
|
|
docker compose run --rm node npm install
|
|
docker compose run --rm node npm test
|
|
docker compose run --rm node npm run build
|
|
```
|
|
|
|
- Tests are `.ts` and run directly under Node's type stripping — no build step in the dev loop.
|
|
- Source imports use `.ts` extensions; `rewriteRelativeImportExtensions` emits `.js` into `dist`.
|
|
- `erasableSyntaxOnly` is on, so no enums, no namespaces, no parameter properties. Use `as const`
|
|
objects plus union types.
|
|
- The published floor is Node 18, but the dev container runs Node 24 (type stripping needs it). CI
|
|
compiles the tests and runs them on 18/20/22/24, so the floor is verified rather than asserted.
|
|
- `typescript` is pinned to the 6.x line because `typescript-eslint` peer-requires `<6.1.0`. Move to
|
|
TypeScript 7 once that constraint lifts.
|
|
|
|
## Defects found in 0.4.0
|
|
|
|
Confirmed by reading the 0.4.0 source. The rewrite fixes all of them; each needs a regression test
|
|
naming the behaviour, and the wire-affecting ones are cross-checked against a reference
|
|
implementation (see todo.md).
|
|
|
|
| Defect | 0.4.0 behaviour |
|
|
| --- | --- |
|
|
| LATIN1 never decodes | `decodeMsg` loops `consts.ENCODING` without breaking, so `data_coding` 0x03 lands on the alias `ISO_8859_1`, which has no decoder, and silently falls back to ASCII |
|
|
| Short segments | `splitMsg` accumulates a full segment then pushes `msgPart.slice(0, -1)`, so every segment is one character short: 152 GSM characters instead of 153, 66 UCS2 instead of 67. Long messages are split into more segments than they need, and each extra segment is billed |
|
|
| DLR month off by one | `smppDate()` uses `getMonth()` (0-based) without `+1`, so January renders as `00` |
|
|
| Non-standard DLR status | Receipts emit `stat:UNDELIVERABLE`; the spec's field is 7 characters (`UNDELIV`) |
|
|
| Flash destroys UCS2 | `flash: true` overwrites `data_coding` with 0x10, discarding the UCS2 alphabet, which needs 0x18 |
|
|
| Shared concat reference | The concatenation reference counter is a module-level global shared by every session in the process |
|
|
| `send()` never times out | Each call adds a listener keyed on the sequence number; a peer that never answers leaks it and the promise never settles |
|
|
| `tls: true` is not TLS | Constructs a bare `new tls.Socket()` with no handshake instead of `tls.connect()` |
|
|
| Alphanumeric sender TON | `sendSms` hardcodes `source_addr_ton` to 1 (international) even for alphanumeric senders, which require TON 5 |
|
|
| Text-only DLRs refused | `deliver_sm` without both `message_state` and `receipted_message_id` TLVs is rejected with `ESME_RINVTLVSTREAM`, so Kannel-style receipts are unusable |
|
|
| Unbounded reassembly | Incomplete long-SMS groups are capped by nothing and swept only when other traffic arrives, after 24 hours |
|
|
| Dead DLR aggregation | `longSmsDlrs` is allocated to merge per-segment receipts and then never used |
|
|
| Trailing NULL truncation | `types.buffer.size()` subtracts one whenever the value's last octet is `0x00`, so the PDU is allocated one octet short while `sm_length` still reports the full length. Any UCS2 message ending in a character like U+4E00 or U+3000 goes out corrupt |
|
|
| Dormant filters | `defs.filters` is declared on commands and TLVs but never invoked anywhere. Dropped in the rewrite; SMPP time formatting is exported as `smppTime` instead |
|
|
| Unchecked reads | Wire reads index straight into the buffer, so a short or malformed PDU throws out of the codec. Reads are bounds-checked and return results now |
|
|
| Unrangechecked writes | Integer params are handed to `writeUInt8`/`writeUInt16BE` unvalidated, so an out-of-range value throws from inside Node |
|
|
| `submit_multi` missing `sm_length` | The field is commented out of the command table, so `short_message` never round-trips for that command |
|
|
| Per-parameter defaults never applied | `calcCmdLength` reads `paramType.default` (the wire type's) rather than the parameter's, so `interface_version: 0x50` on the bind commands did nothing and every bind declared version 0x00 |
|
|
| `source_telematics_id` width | Defined as a 2-octet integer; SMPP 3.4 5.3.2.8 makes it 1 octet, unlike `dest_telematics_id`, which really is 2 |
|
|
| Binary payloads decoded as text | `data_coding` 0x02, 0x04, 0x14 and 0xF4-0xF7 are 8-bit binary and land on the GSM 03.38 table, which rewrites every octet outside it. They resolve to LATIN1 now, so the payload survives as bytes |
|
|
| `ESME_RINVBCASTCHANIND` typo | Defined as `0x011`, three hex digits; the spec value is `0x0112` |
|
|
|
|
## Multipart sends and the send window
|
|
|
|
`sendSms` puts every segment of a message on the wire together instead of waiting for each response
|
|
in turn. This is not an optimisation: this library's own server holds segments until the whole
|
|
message is reassembled before it answers any of them, so sending them one-after-a-response
|
|
deadlocks. It follows that a message with more segments than `maxOutstanding` cannot be delivered to
|
|
a server that defers responses that way — real SMSCs answer each `submit_sm` immediately, so this
|
|
only bites when both ends are this library.
|
|
|
|
## GSM 7-bit is sent unpacked
|
|
|
|
Over SMPP the ESME puts one GSM character per octet in `short_message` and the SMSC packs it into
|
|
septets. The 140-octet limit applies to that packed result, not to what goes on the wire here, which
|
|
is why a concatenated segment is 153 characters plus a 6-octet UDH — 159 octets in `short_message`,
|
|
and entirely correct. Do not "fix" this to 134; that number is the packed payload size and would
|
|
truncate every long message by a fifth.
|
|
|
|
UCS2 is not packed, so there the two coincide: 67 characters = 134 octets, plus the 6-octet UDH is
|
|
exactly 140.
|
|
|
|
## Conventions
|
|
|
|
- Hard tabs. Alphabetical ordering for keys, imports and lists unless order is logic-significant.
|
|
Two deliberate exceptions: command parameters are in wire order (above), and the `errors` and TLV
|
|
tables are ordered by their numeric id so they can be diffed against the spec and gaps stay visible.
|
|
- Comments are the exception, not the default — see the root `CLAUDE.md` rules. Do not write file
|
|
preambles or restate what the code says.
|
|
- Test data uses real randomised UUID v7 values, never `aaaa-0000` placeholders.
|
|
- `message_id` values the library generates are UUID v7.
|
|
- A test that needs a dummy peer must `resume()` its sockets. An unread socket never processes the
|
|
peer's FIN, so `server.close()` hangs forever — that is a test bug, not a library one.
|
|
- Everything a test opens gets its teardown registered as it is opened, never closed on the test's
|
|
last line: an assertion that throws skips that line, and the listener it leaves behind keeps
|
|
`node --test` alive until CI's ten-minute cap. `test/teardown.ts` covers a session, a server and a
|
|
listener; anything else takes a bare `t.after`. Its close aborts rather than drains, so a test that
|
|
fails holding the send window still ends.
|
|
- `t.after` hooks run in registration order, so registering at creation tears the outermost resource
|
|
down first. A teardown that waits on a listener must destroy that listener's own connections before
|
|
it waits, or be registered after the hook that does — `net.Server.close()` does not call back until
|
|
every connection on it is gone.
|
|
- `assert.equal` from `node:assert/strict` narrows its first argument, so a following `?.` on the
|
|
same value is flagged as unnecessary. Assert once with `assert.ok(x)` and use plain access after.
|
|
|
|
## Decisions
|
|
|
|
- **A close arriving after our own `unbind` is a clean unbind, not an error.** Maintainer's call,
|
|
2026-08-26: most SMSCs drop the socket instead of answering, so the documented shutdown would
|
|
otherwise always report a failure. It does mask a socket that died mid-unbind for an unrelated
|
|
reason, which is accepted — the peer sees the same TCP close either way.
|
|
- **The published surface is frozen at what `src/index.ts` exports today.** `Session` is exported and
|
|
publicly constructible, which is why `SessionOptions` and `ReconnectOptions` are public too — that
|
|
is correct, not a leak, and it has been raised twice. The collaborators `session.ts` delegates to
|
|
(`Reassembler`, `PendingRequests`, `SendWindow`, `ReconnectLoop`, `LinkTimers`, `LinkGate`,
|
|
`DlrMerger`, `submitSms`) stay unpublished so they can be reshaped.
|
|
- **The sub-3.4 optional-parameter rule is a predicate, not a chokepoint.** `acceptsOptionalParams()`
|
|
is consulted by the library's own senders; `session.send({ tlvs })` is passed through as written,
|
|
because silently stripping a caller's explicit TLVs off a deliberately public low-level surface
|
|
would be worse than sending them. The guarantee is "what this library sends honours the rule",
|
|
never "the session cannot send optional parameters to an old peer".
|
|
- **Both ends feed `peerInterfaceVersion`, and a peer that declared nothing is pre-3.4.**
|
|
`acceptBind()` records what the ESME declared in its bind request; the client's `bind()` records
|
|
the `sc_interface_version` the SMSC answered with. A peer that declared no version is recorded as
|
|
`undeclaredInterfaceVersion` (0x00) and is sent no optional parameters — the spec reads an absent
|
|
`sc_interface_version` as an SMSC that supports none. `undefined` is left to mean one thing only:
|
|
no bind has been accepted on this session yet.
|
|
- **The library speaks SMPP 3.4 on the wire, and `defs/` keeps the 5.0 tables as a superset.**
|
|
Maintainer's call, 2026-08-26: 3.4 is what SMSCs actually run, while the wider tables let the codec
|
|
parse and build whatever a peer sends. The declared version is an option on both `client()` and
|
|
`server()`, so an implementation that needs 5.0 throughout can have it. The threshold at or above
|
|
which a peer may be sent optional parameters is fixed at 0x34 by the spec and is not the same
|
|
constant as the declared version.
|
|
- **Bind direction is enforced on the library's own senders and on everything incoming, not on
|
|
`send()`.** A receiver-bound ESME carries no `submit_sm` and a transmitter-bound one no
|
|
`deliver_sm`; `sendSms()` and `sendDlr()` refuse locally, and an arriving PDU is answered
|
|
`ESME_RINVBNDSTS`. `bindAllows()` is a predicate on the same footing as `acceptsOptionalParams()`,
|
|
so the deliberately public low-level `send()` stays a passthrough. Only those two commands are
|
|
policed, because they are the only ones the library sends and dispatches by direction.
|
|
|
|
- **The logger is a five-method contract this library declares, not a dependency.** `SmppLog` in
|
|
`log.ts` is what the code actually calls (`debug`, `error`, `info`, `verbose`, `warn`), so an
|
|
application can satisfy it with an object literal and `@larvit/smpp` ships with no runtime
|
|
dependencies. `@larvit/log` implements it structurally and stays a devDependency, where
|
|
`test/tls.test.ts` passing a real `Log` as the server's logger keeps that compatibility compiled.
|
|
|
|
- **`esm_class` decides what a `deliver_sm` is, and the body is only read when it names nothing.**
|
|
Message type `MC_DELIVERY_RECEIPT` (0x04) makes it a receipt whatever the body parses to, so a
|
|
receipt in a format `dlrFromPdu()` cannot read reaches `dlr` with `smsId` undefined instead of
|
|
arriving as an inbound SMS. Any other named type — delivery or user acknowledgement, conversation
|
|
abort, intermediate notification — is not a receipt and its body is not scraped. A message type of
|
|
0, or one of the ten the spec reserves, keeps the scrape: SMSCs that send text-only receipts leave
|
|
`esm_class` at 0, and reading that as the spec's "default message type" would lose every one of
|
|
them. A non-empty `receipted_message_id` TLV marks a receipt on the same footing there, since
|
|
nothing but a receipt carries one. What gets scraped is the decoded `short_message` with any UDH
|
|
stripped; a receipt this library recognises never reaches the reassembler, so an SMSC that splits one across segments gets a
|
|
`dlr` per segment rather than one merged report.
|
|
The `message_state` TLV is authoritative only where it names a state in the table — SMPP reserves
|
|
0x80-0xFF for MC-vendor-specific values, so an unnameable one keeps its raw `statusId` and leaves
|
|
`statusMsg` to the body.
|
|
|
|
- **A listener that rejects is routed by Node's `captureRejections`, not by hand-dispatching.** Both
|
|
emitters construct with `captureRejections: true` and implement
|
|
`[EventEmitter.captureRejectionSymbol]`, which lands a rejected `async` listener on `sessionError`
|
|
or `serverError` beside the synchronous guard in `emit()`. Dispatching `rawListeners()` from
|
|
`emit()` instead needs a cast to call them with the event's argument tuple, which hard rule 4
|
|
forbids. A rejection reason is `unknown` and `String()` throws on a null-prototype object, so both
|
|
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.
|
|
|
|
- **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 => …)` the README documents reads as a misused promise in any strict
|
|
consumer. `declare on: …` and its six siblings re-type the inherited methods to return `unknown`,
|
|
which emits nothing, needs no cast and leaves the runtime method on the prototype. Overriding them
|
|
as real methods instead cannot work: the `super.on()` call needs a cast to satisfy the conditional
|
|
`Listener` type. The cost is that a subclass can no longer reach those seven through `super` or
|
|
override them as methods — re-declaring them the same way is its way out. `unknown` rather than `void | Promise<void>` because a listener may return
|
|
anything — `session.on('close', () => set.delete(session))` returns a boolean.
|
|
|
|
- **A reconnect keeps the delivery-receipt merges; everything else the link held is dropped.**
|
|
`onDeliverSm()` answers each receipt before the group it belongs to is complete, and `teardown()`
|
|
runs on every path — an idle timeout and a failed rebind, not only `close()` — so clearing the
|
|
merges there loses receipts no peer has a reason to send again. They are cleared where the session
|
|
is over instead: `close()`, or a drop with no reconnect loop left to bring the link back. Inbound
|
|
segments stay in `teardown()`, because they go unanswered until the message is whole: the peer
|
|
still holds them, and answering it on a later link with the old segments' sequence numbers would
|
|
correlate with nothing. Surviving a process restart is a separate, public-surface question, and is
|
|
in todo.md.
|
|
|
|
- **A message id base is merged at most once.** A receipt carries nothing but `<base>-<n>`, so a
|
|
straggler for a message whose group is gone cannot be told from a receipt for a later message the
|
|
peer handed the same ids — an SMSC whose id counter restarts with its process is the realistic
|
|
case. `DlrMerger` remembers the bases it has finished with, capped and expiring exactly like the
|
|
groups, and refuses to open one a second time: the later message gets no `messageDlr`, and an
|
|
earlier one whose receipts are still arriving is dropped rather than left to collect the later
|
|
one's. Every segment still reaches the application as a `dlr`. The rule covers the bases the
|
|
merger opened — `expect()` ignores a lone id, so a single-part message never claims one.
|
|
|
|
- **A deliberate shutdown drains; an unusable link and an abort do not.** `close()` and `unbind()`
|
|
refuse further sends and wait on the send window, not the pending map — the map misses a segment
|
|
still queued behind a full window, and finishing a half-sent multipart message is the point. The
|
|
window counts slots, never outcomes, so it says when to stop waiting and nothing about what
|
|
happened: it empties on a drop too, where `teardown()` settles everything the link was carrying,
|
|
which is why `drain()` reads `closed` before it reads the count. The wait covers what this end
|
|
sent — a request the peer sent us is answered through `sendReturn()`, which never enters the
|
|
window, so a server session waits for none of its inbound work; that half is in todo.md.
|
|
`shutdownTimeout` bounds the drain alone, and `0` waits forever like every other timeout here;
|
|
`unbind()` then waits `responseTimeout` for its own response, and sends that PDU through
|
|
`request()` past both the window and the drain gate because it must go out either way. A stream
|
|
the framer or the codec cannot read takes `teardown()` instead, and `close({ signal })` on an
|
|
aborted signal and a peer's own `unbind` take `end()` — nothing on a dead link can answer, an abort
|
|
means stop now, and a peer that has declared itself finished will not answer what it still owes us,
|
|
so draining any of the three would only hold a socket open for the timeout. `SmppServer.close()`
|
|
reports each session's unfinished drain through `serverError`, because its own result says
|
|
nothing but that the listener stopped. `shutdownTimeout` stays a session option rather than a
|
|
`close()` argument: `server()` builds sessions on the caller's behalf, so the option
|
|
is the only composition point, and `close({ signal })` already covers a hard deadline.
|
|
|
|
- **The TLS tests build their own self-signed certificate in DER** (`test/tls.test.ts`) instead of
|
|
adding a devDependency or shelling out to openssl. Maintainer's call, 2026-08-26: the dev image
|
|
`node:24.18.0-bookworm-slim` ships no openssl binary, so a shelled-out fixture would pass in CI
|
|
and fail on every developer machine, and a committed key leaks in a public repository. Valid while
|
|
the dev image has no openssl.
|
|
|
|
- **`close` means the session is over, and a drop the loop will retry is `disconnected`.** Maintainer's
|
|
call, 2026-08-31: with reconnect on by default a `close` on every transient drop left an application
|
|
unable to tell a retry from the end, and no second one follows because `teardown()` is a no-op once
|
|
`closed`. `teardown()` picks the event by whether the reconnect loop is still live, and `end()` stops
|
|
that loop before tearing down, so every deliberate shutdown emits `close`. Without the split an
|
|
application that opens a replacement client on `close` ends up holding two binds on one account.
|
|
|
|
- **Only a link that outlasted `maxDelay` resets the backoff.** Coming up is not proof it works: an
|
|
unreadable stream is found after the bind returns, so resetting there gave a link that died on
|
|
arrival a fresh `minDelay` every cycle — one TCP connect and bind per second, forever, which is how
|
|
an account gets blocked for bind flooding. `ReconnectLoop` records when it brought the owner up and
|
|
resets only if the link then lasted longer than the longest wait it would ever schedule. A drop
|
|
after a healthy link still retries at `minDelay`.
|
|
|
|
- **A stream this library cannot read is a dead link, not a dead session.** Maintainer's call,
|
|
2026-08-31: a framing or codec error tears the link down through `teardown()`, so the reconnect
|
|
loop retries it on a fresh socket with a fresh framer — which is what a desynced stream needs, and
|
|
the common cause. `sessionError` still carries every failure, so a peer that only ever sends
|
|
garbage is visible in the log rather than silent, and the backoff grows to one attempt per
|
|
`maxDelay`.
|
|
|
|
- **`disconnected` counts failed links, not outages.** A retry that opens a socket and then loses it
|
|
clears `closed` through `attach()`, so the next `teardown()` emits again: each emission is a link
|
|
that went down, which makes the event deliberately not one-to-one with `reconnected`. Suppressing
|
|
the later ones would leave a failed rebind with nothing but a log line.
|
|
|
|
- **`session.sock` is a getter over `PduTransport`, and stays public.** Maintainer's call, 2026-08-31:
|
|
`session.ts` had reached its line cap, so the socket-to-PDU seam todo.md named was opened —
|
|
`PduTransport` owns the socket, the framer and the parse, and hands the session raw bytes, framed
|
|
PDUs, parsed ones and an unreadable stream. Reading `session.sock` is unchanged; assigning it no
|
|
longer compiles, which never rewired the handlers and so never worked. The transport stays
|
|
unpublished like the other collaborators.
|
|
|
|
- **A client re-binds after a drop unless it is told not to.** Maintainer's call, 2026-08-31:
|
|
surviving a dropped link is most of what the session layer is for, and behind an opt-in an
|
|
application that never read the options table got none of it. `reconnect` takes
|
|
`{ minDelay, maxDelay }` to retune the backoff and `false` to turn it off, so absent means on and
|
|
there is one spelling for each. Only `client()` reconnects — a `server()` session is a connection
|
|
the peer opened, and nothing at this end can reopen it. The retry timer is `unref()`'d, so a
|
|
process with nothing else left to do still exits between attempts.
|
|
|
|
- **The notation a peer writes message ids in is named per place, and normalisation never reaches
|
|
inside a `<base>-<n>` id.** An SMSC may answer `submit_sm_resp` in hex and write the receipt's
|
|
`id:` in decimal, so one transform over both sides cannot make them equal — `smsIdFormat` names
|
|
`receipt` and `submitResp` separately and reads both into a plain decimal value before `smsIds`
|
|
and `dlr.smsId` are compared. `submitResp` covers the `receipted_message_id` TLV too, which SMPP
|
|
3.4 5.3.2.26 defines as the id the `submit_sm_resp` carried: naming one notation for whichever id
|
|
a receipt yields would break the peer that sends both, whose TLV correlated before the option was
|
|
set. Omitting a place is what leaving it alone means, so there is no
|
|
`raw` notation, and a caller-supplied formatter is refused because it would make the promise that
|
|
those two are comparable unverifiable — `onRequest` and the PDU on the `dlr` event are the escape
|
|
hatches, and the `onReceipt` hook in todo.md is the seam if one is wanted. An id no notation reads
|
|
is left exactly as it arrived, which is what keeps `DlrMerger` working: a `<base>-<n>` id parses
|
|
as no number and so reaches `expect()` and `collect()` unchanged. Normalising the base instead
|
|
would break that pair. The option is on `client()` only — a `server()` session generates its own
|
|
ids and writes its own receipts, so both places are already one notation.
|
|
|
|
- **A send waits for the next link only if it never reached the socket; one that did is counted, not
|
|
resent.** Maintainer's call, 2026-09-01: re-queueing everything unanswered would resend a
|
|
`submit_sm` the SMSC accepted and answered into a dead socket, which is delivered and billed
|
|
twice, while a request that never left this process can be lost for free. `LinkGate` holds a send
|
|
that has no link, so a send issued between links and a segment still queued behind a full window
|
|
when the drop hit both go out on the new one. Once a request has been written, every way it can
|
|
fail — the link dropping under it, `responseTimeout` expiring, the caller's own abort — means the
|
|
peer may have taken it, so `attempt()` wraps all three in `UnansweredError` and `collectSent()`
|
|
counts them into `SendSmsResult.unanswered`. Counting only the dropped-link case, as the first cut
|
|
did, would have called the commonest one safe to resend. A count rather than a boolean because
|
|
`sendSms()` aggregates segments into one `err` slot, and required rather than optional so every
|
|
construction site answers. `UnansweredError` stays unexported: `unanswered` is the one spelling on
|
|
the public surface, and a `send()` error that is neither a build failure nor a pre-write abort
|
|
means the same thing.
|
|
The hold is bounded by `responseTimeout` rather than an option of its own — that is already the
|
|
answer to how long one request may wait. It bounds the hold and the answer separately, and the
|
|
wait for a `maxOutstanding` slot is bounded by nothing, so `responseTimeout` is not a deadline for
|
|
the call; `SendOptions.signal` with `AbortSignal.timeout()` is, and both the gate and
|
|
`pending.wait()` honour it.
|
|
|
|
- **The gate decides whether a link can carry a request, and a bind is what makes it one.**
|
|
Maintainer's call, 2026-09-01: `attach()` clears `closed` the moment a socket is handed over, one
|
|
round trip before the bind is answered, so gating on `closed` let a send arriving in that window
|
|
go out unbound and come back `ESME_RINVBNDSTS` while a send that arrived a millisecond earlier was
|
|
held correctly. `LinkGate` owns the answer instead — `shut(returning)` on every teardown,
|
|
`open()` only once `comeBackUp()` has a bound link — and `Session.linkDown()` reads it rather than
|
|
`closed`. The bind itself cannot wait for what it creates, so `send()` lets the three bind
|
|
commands past the gate and the window, the same door `unbind()` takes through `attempt()`. That
|
|
keeps the exemption a predicate on the command, like the `_resp` guard beside it, rather than a
|
|
second `send()` on the public surface or a changed `ReconnectOptions.onConnected`.
|
|
The gate is told what happened and never reads back into the session: a collaborator that has to
|
|
ask does not own its decision, which is how the first cut ended up answering the same question two
|
|
different ways at admit and at release.
|