diff --git a/AGENTS.md b/AGENTS.md index ff4389b..593195a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -309,6 +309,7 @@ the file. - A store at its bound answers `ESME_RTHROTTLED` to a submission and `ESME_RX_T_APPN` to a delivery, a `data_sm` by whichever it stands in for. - A reconnect keeps the delivery-receipt merges; everything else the link held is dropped. +- The bind state is the session's, `bound()` alone writes it, and it holds through a reconnect's gap. - 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 02e45dd..208bcaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,10 @@ `alert_on_message_delivery` and `broadcast_area_identifier`, the names they read back under. The two alternate names are gone from `tlvs` too, which is now typed by `TlvName`: narrow a `string` with `isTlvName()` before indexing it. - `cmds.broadcast_sm_resp.tlvMap` is removed; nothing read it. +- `session.boundAs` and `session.peerInterfaceVersion` are read-only, and `session.loggedIn` is + removed: read `session.boundAs !== undefined`. A session you wire yourself records the bind it + accepted or had accepted with `session.bound(bindType, declaredVersion)`; an assignment to either + field now throws a `TypeError`. ## 0.5.0 diff --git a/README.md b/README.md index ba17dc9..4eaed8a 100644 --- a/README.md +++ b/README.md @@ -421,8 +421,12 @@ const { err, pduObj } = await session.send({ - `acceptsOptionalParams()`: whether the peer declared SMPP 3.4 or later, the version from which optional parameters may be sent to it. The library's own senders check it before attaching a TLV; a `send()` you build is passed through as written, so check it yourself. -- `peerInterfaceVersion`: the version the peer declared, `0x00` if none. +- `peerInterfaceVersion`: the version the peer declared, `0x00` if none, `undefined` before any bind. - `bindAllows(cmdName)` and `boundAs`: what the bind direction carries: [Bind direction](#bind-direction). +- Both hold through a reconnect's gap: the loop binds again as before. +- `bound(bindType, declaredVersion)`: how a session you construct yourself records a bind, whichever + end accepted it. A `declaredVersion` that is not a number is none declared. Both fields are + read-only. ## Receiving in depth @@ -539,7 +543,8 @@ if (err) throw err; - `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 - peer's bind gets accepted, since a hand-wired session has no bind handling of its own. + peer's bind gets accepted, since a hand-wired session has no bind handling of its own: call + `session.bound(bindType, pduObj.params.interface_version)` before answering it. **`sendDlr()`** takes `SCHEDULED`, `ENROUTE`, `DELIVERED`, `EXPIRED`, `DELETED`, `UNDELIVERABLE`, `ACCEPTED`, `UNKNOWN`, `REJECTED` or `SKIPPED`. The first two go out as intermediate delivery diff --git a/docs/decisions.md b/docs/decisions.md index b39885f..0cf1ca0 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -715,6 +715,15 @@ rule and an index of the titles below. assembled that way. What goes there is traffic already answered, which is why each group reaches `sessionError` like every other one given up on. +- **The bind state is the session's, `bound()` alone writes it, and it holds through a reconnect's + gap.** Maintainer's call, 2026-09-28. `client()` and `server()` call `bound()` as a hand-wired + session does, so one method is the one spelling, and `loggedIn` went as a second spelling of + `boundAs !== undefined`. Clearing the state at `teardown()` was rejected: `bindAllows()` and + `acceptsOptionalParams()` then answer yes to everything while the link is down, so a + receiver-bound client queues a `submit_sm` the peer refuses and a receipt built then carries TLVs a + pre-3.4 peer must not get — goal 4. Valid while the reconnect loop binds again with the same bind + type to the same peer. + - **A message id base is merged at most once.** A receipt carries nothing but `-`, so a straggler for a message whose group is gone cannot be told from a receipt for a later message the peer handed the same ids — an SMSC whose id counter restarts with its process is the realistic diff --git a/src/client.ts b/src/client.ts index f20e831..8f727eb 100644 --- a/src/client.ts +++ b/src/client.ts @@ -8,7 +8,7 @@ export type { BindType }; import { ReconnectLoop } from './reconnect-loop.ts'; import { Session } from './session.ts'; -import { checkSessionOptions, undeclaredInterfaceVersion } from './session-options.ts'; +import { checkSessionOptions } from './session-options.ts'; import { connect as netConnect } from 'node:net'; import { connect as tlsConnect } from 'node:tls'; import { defaultInterfaceVersion } from './defs/constants.ts'; @@ -174,13 +174,7 @@ async function bind(session: Session, options: ClientOptions): Promise, ): Promise { - const declared = pduObj.params.interface_version; - - session.boundAs = bindTypeFromCommand(pduObj.cmdName); - session.loggedIn = true; - session.peerInterfaceVersion = typeof declared === 'number' - ? declared - : undeclaredInterfaceVersion; + session.bound(bindType, pduObj.params.interface_version); await session.sendReturn(pduObj, 'ESME_ROK', identity, bindRespTlvs(session, options)); } -async function onBind(session: Session, pduObj: PduObject, options: ServerOptions): Promise { +async function onBind( + session: Session, + pduObj: PduObject, + bindType: BindType, + options: ServerOptions, +): Promise { const identity = { system_id: options.systemId ?? defaults.systemId }; const systemId = paramText(pduObj.params.system_id); @@ -198,7 +198,7 @@ async function onBind(session: Session, pduObj: PduObject, options: ServerOption return; } - await acceptBind(session, pduObj, options, identity); + await acceptBind(session, pduObj, bindType, options, identity); session.log.verbose('server - bound', { systemId }); } @@ -211,16 +211,16 @@ async function handleRequest( pduObj: PduObject, options: ServerOptions, ): Promise { - const isBind = bindCommands.includes(pduObj.cmdName); + const bindType = bindTypeFromCommand(pduObj.cmdName); - if (session.loggedIn) { - if (isBind || !options.onRequest) return false; + if (session.boundAs !== undefined) { + if (bindType || !options.onRequest) return false; return options.onRequest(session, pduObj); } - if (isBind) { - await onBind(session, pduObj, options); + if (bindType) { + await onBind(session, pduObj, bindType, options); return true; } diff --git a/src/session.ts b/src/session.ts index 614c0b5..64c90a2 100644 --- a/src/session.ts +++ b/src/session.ts @@ -19,7 +19,7 @@ import { ReconnectLoop } from './reconnect-loop.ts'; import { leftOf } from './idle-waiters.ts'; import { errorFrom } from './error-from.ts'; import { optionalParamsMinVersion } from './defs/constants.ts'; -import { bindCarries, bindCommands, defaultSystemId, defaults } from './session-options.ts'; +import { bindCarries, bindCommands, defaultSystemId, defaults, undeclaredInterfaceVersion } from './session-options.ts'; import { isResp, objToPdu, pduReturn } from './pdu.ts'; import { refusalAnswer } from './pdu-refusal.ts'; import { guardedLog } from './log.ts'; @@ -54,15 +54,12 @@ export class Session extends EventEmitter { readonly log: SmppLog; - /** The role the ESME bound with, whichever end of the link this is. Undefined before any bind. */ - boundAs: BindType | undefined = undefined; /** Which end of the link this is. `server()` sets it; a hand-wired SMSC must set it too. */ linkEnd: LinkEnd = 'esme'; - loggedIn = false; - /** What the peer declared when binding: 0x00 if it declared none, undefined before any bind. */ - peerInterfaceVersion: number | undefined = undefined; userData: unknown = undefined; + private bind: { as: BindType; peerVersion: number } | undefined = undefined; + private readonly concatReference = new ConcatReference(); private readonly dlrMerger: DlrMerger; private readonly incoming: IncomingRequests; @@ -154,6 +151,23 @@ export class Session extends EventEmitter { return this.transport.sock; } + /** The role the ESME bound with, whichever end of the link this is. Undefined before any bind. */ + get boundAs(): BindType | undefined { + return this.bind?.as; + } + + /** What the peer declared when binding: 0x00 if it declared none, undefined before any bind. */ + get peerInterfaceVersion(): number | undefined { + return this.bind?.peerVersion; + } + + /** Records a bind this link accepted or had accepted, until the next one. A version that is not a number is none. */ + bound(bindType: BindType, declaredVersion: unknown): void { + const peerVersion = typeof declaredVersion === 'number' ? declaredVersion : undeclaredInterfaceVersion; + + this.bind = { as: bindType, peerVersion }; + } + /** Whether this session's bind direction carries a command. Consulted by the library's senders. */ bindAllows(cmdName: string): boolean { return bindCarries(this.boundAs, cmdName, this.linkEnd); @@ -171,15 +185,13 @@ export class Session extends EventEmitter { } /** Answers a request the peer sent us. Responses are never waited on. */ - async sendReturn( + sendReturn( pdu: PduObject, status: ErrorName = 'ESME_ROK', params: Record = {}, tlvs?: TlvInputs, ): Promise { - return Promise.resolve( - this.answer(pduReturn(pdu, status, params, tlvs), pdu.cmdName, pdu.seqNr), - ); + 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 { diff --git a/todo.md b/todo.md index f3dced1..e99834f 100644 --- a/todo.md +++ b/todo.md @@ -204,11 +204,6 @@ and 5. Every seat ranked the session's lifecycle hardest and least wanted to mod - [ ] **Lift Locality to 7, and confirm it with a scoring run.** A run reading 7.0 or above also retires the #30 decision. The sub-items are what the 2026-09-28 run named, most seats first. -- [ ] **Let `Session` own its bind state.** `client.ts` and `server.ts` write `boundAs`, `loggedIn` - and `peerInterfaceVersion` onto the session from outside, and `loggedIn` duplicates - `boundAs !== undefined`. A method such as `session.bound(bindType, declaredVersion)` with the - three fields read-only changes the public surface a hand-wired SMSC uses, so it needs the - maintainer's call first. All four seats. - [ ] **Name what `request()`, `carry()` and `now()` each skip.** Three ways onto the wire differ only in which of the drain, the gate and the window they bypass, and none of the names says which. Three seats.