Let the session own its bind state, written by bound() alone #42

Merged
lilleman merged 11 commits from session-bound into main 2026-09-28 04:37:29 +02:00
8 changed files with 62 additions and 42 deletions
Showing only changes of commit b70f0d6ade - Show all commits
+1
View File
@@ -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 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 `data_sm` by whichever it stands in for.
- A reconnect keeps the delivery-receipt merges; everything else the link held is dropped. - 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 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 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. - A send queued for a send-window slot is bounded by the caller's `signal`, and by nothing else.
+4
View File
@@ -74,6 +74,10 @@
`alert_on_message_delivery` and `broadcast_area_identifier`, the names they read back under. The `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. 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. - `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 ## 0.5.0
+7 -2
View File
@@ -421,8 +421,12 @@ const { err, pduObj } = await session.send({
- `acceptsOptionalParams()`: whether the peer declared SMPP 3.4 or later, the version from which - `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; 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. 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). - `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 ## 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 - `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. 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 - 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`, **`sendDlr()`** takes `SCHEDULED`, `ENROUTE`, `DELIVERED`, `EXPIRED`, `DELETED`, `UNDELIVERABLE`,
`ACCEPTED`, `UNKNOWN`, `REJECTED` or `SKIPPED`. The first two go out as intermediate delivery `ACCEPTED`, `UNKNOWN`, `REJECTED` or `SKIPPED`. The first two go out as intermediate delivery
+9
View File
@@ -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 assembled that way. What goes there is traffic already answered, which is why each group reaches
`sessionError` like every other one given up on. `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 `<base>-<n>`, so a - **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 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 peer handed the same ids — an SMSC whose id counter restarts with its process is the realistic
+2 -8
View File
@@ -8,7 +8,7 @@ export type { BindType };
import { ReconnectLoop } from './reconnect-loop.ts'; import { ReconnectLoop } from './reconnect-loop.ts';
import { Session } from './session.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 netConnect } from 'node:net';
import { connect as tlsConnect } from 'node:tls'; import { connect as tlsConnect } from 'node:tls';
import { defaultInterfaceVersion } from './defs/constants.ts'; import { defaultInterfaceVersion } from './defs/constants.ts';
@@ -174,13 +174,7 @@ async function bind(session: Session, options: ClientOptions): Promise<VoidResul
return { err: new Error(`Remote host refused login: ${sent.pduObj.cmdStatus ?? 'unknown'}`) }; return { err: new Error(`Remote host refused login: ${sent.pduObj.cmdStatus ?? 'unknown'}`) };
} }
const declared = sent.pduObj.tlvs.sc_interface_version?.tagValue; session.bound(bindType, sent.pduObj.tlvs.sc_interface_version?.tagValue);
session.boundAs = bindType;
session.loggedIn = true;
session.peerInterfaceVersion = typeof declared === 'number'
? declared
: undeclaredInterfaceVersion;
session.log.info('client - bound', { bindType, systemId }); session.log.info('client - bound', { bindType, systemId });
return {}; return {};
+17 -17
View File
@@ -1,12 +1,12 @@
import type { CloseOptions, OnRequest } from './session-options.ts'; import type { BindType, CloseOptions, OnRequest } from './session-options.ts';
import type { PduObject, TlvInputs } from './pdu.ts'; import type { PduObject, TlvInputs } from './pdu.ts';
import type { Result, VoidResult } from './result.ts'; import type { Result, VoidResult } from './result.ts';
import type { Server as NetServer, Socket } from 'node:net'; import type { Server as NetServer, Socket } from 'node:net';
import type { Server as TlsServer, TlsOptions } from 'node:tls'; import type { Server as TlsServer, TlsOptions } from 'node:tls';
import type { SmppLog } from './log.ts'; import type { SmppLog } from './log.ts';
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { Session, bindCommands, defaultSystemId } from './session.ts'; import { Session, defaultSystemId } from './session.ts';
import { bindTypeFromCommand, checkSessionOptions, undeclaredInterfaceVersion } from './session-options.ts'; import { bindTypeFromCommand, checkSessionOptions } from './session-options.ts';
import { createServer as createNetServer } from 'node:net'; import { createServer as createNetServer } from 'node:net';
import { createServer as createTlsServer } from 'node:tls'; import { createServer as createTlsServer } from 'node:tls';
import { defaultInterfaceVersion } from './defs/constants.ts'; import { defaultInterfaceVersion } from './defs/constants.ts';
@@ -173,21 +173,21 @@ function bindRespTlvs(session: Session, options: ServerOptions): TlvInputs | und
async function acceptBind( async function acceptBind(
session: Session, session: Session,
pduObj: PduObject, pduObj: PduObject,
bindType: BindType,
options: ServerOptions, options: ServerOptions,
identity: Record<string, string>, identity: Record<string, string>,
): Promise<void> { ): Promise<void> {
const declared = pduObj.params.interface_version; session.bound(bindType, pduObj.params.interface_version);
session.boundAs = bindTypeFromCommand(pduObj.cmdName);
session.loggedIn = true;
session.peerInterfaceVersion = typeof declared === 'number'
? declared
: undeclaredInterfaceVersion;
await session.sendReturn(pduObj, 'ESME_ROK', identity, bindRespTlvs(session, options)); await session.sendReturn(pduObj, 'ESME_ROK', identity, bindRespTlvs(session, options));
} }
async function onBind(session: Session, pduObj: PduObject, options: ServerOptions): Promise<void> { async function onBind(
session: Session,
pduObj: PduObject,
bindType: BindType,
options: ServerOptions,
): Promise<void> {
const identity = { system_id: options.systemId ?? defaults.systemId }; const identity = { system_id: options.systemId ?? defaults.systemId };
const systemId = paramText(pduObj.params.system_id); const systemId = paramText(pduObj.params.system_id);
@@ -198,7 +198,7 @@ async function onBind(session: Session, pduObj: PduObject, options: ServerOption
return; return;
} }
await acceptBind(session, pduObj, options, identity); await acceptBind(session, pduObj, bindType, options, identity);
session.log.verbose('server - bound', { systemId }); session.log.verbose('server - bound', { systemId });
} }
@@ -211,16 +211,16 @@ async function handleRequest(
pduObj: PduObject, pduObj: PduObject,
options: ServerOptions, options: ServerOptions,
): Promise<boolean> { ): Promise<boolean> {
const isBind = bindCommands.includes(pduObj.cmdName); const bindType = bindTypeFromCommand(pduObj.cmdName);
if (session.loggedIn) { if (session.boundAs !== undefined) {
if (isBind || !options.onRequest) return false; if (bindType || !options.onRequest) return false;
return options.onRequest(session, pduObj); return options.onRequest(session, pduObj);
} }
if (isBind) { if (bindType) {
await onBind(session, pduObj, options); await onBind(session, pduObj, bindType, options);
return true; return true;
} }
+22 -10
View File
@@ -19,7 +19,7 @@ import { ReconnectLoop } from './reconnect-loop.ts';
import { leftOf } from './idle-waiters.ts'; import { leftOf } from './idle-waiters.ts';
import { errorFrom } from './error-from.ts'; import { errorFrom } from './error-from.ts';
import { optionalParamsMinVersion } from './defs/constants.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 { isResp, objToPdu, pduReturn } from './pdu.ts';
import { refusalAnswer } from './pdu-refusal.ts'; import { refusalAnswer } from './pdu-refusal.ts';
import { guardedLog } from './log.ts'; import { guardedLog } from './log.ts';
@@ -54,15 +54,12 @@ export class Session extends EventEmitter<SessionEvents> {
readonly log: SmppLog; 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. */ /** Which end of the link this is. `server()` sets it; a hand-wired SMSC must set it too. */
linkEnd: LinkEnd = 'esme'; 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; userData: unknown = undefined;
private bind: { as: BindType; peerVersion: number } | undefined = undefined;
private readonly concatReference = new ConcatReference(); private readonly concatReference = new ConcatReference();
private readonly dlrMerger: DlrMerger; private readonly dlrMerger: DlrMerger;
private readonly incoming: IncomingRequests; private readonly incoming: IncomingRequests;
@@ -154,6 +151,23 @@ export class Session extends EventEmitter<SessionEvents> {
return this.transport.sock; 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. */ /** Whether this session's bind direction carries a command. Consulted by the library's senders. */
bindAllows(cmdName: string): boolean { bindAllows(cmdName: string): boolean {
return bindCarries(this.boundAs, cmdName, this.linkEnd); return bindCarries(this.boundAs, cmdName, this.linkEnd);
@@ -171,15 +185,13 @@ export class Session extends EventEmitter<SessionEvents> {
} }
/** Answers a request the peer sent us. Responses are never waited on. */ /** Answers a request the peer sent us. Responses are never waited on. */
async sendReturn( sendReturn(
pdu: PduObject, pdu: PduObject,
status: ErrorName = 'ESME_ROK', status: ErrorName = 'ESME_ROK',
params: Record<string, ParamValue> = {}, params: Record<string, ParamValue> = {},
tlvs?: TlvInputs, tlvs?: TlvInputs,
): Promise<VoidResult> { ): Promise<VoidResult> {
return Promise.resolve( return Promise.resolve(this.answer(pduReturn(pdu, status, params, tlvs), pdu.cmdName, pdu.seqNr));
this.answer(pduReturn(pdu, status, params, tlvs), pdu.cmdName, pdu.seqNr),
);
} }
private answer(built: Result<{ buffer: Buffer }>, cmdName: string, seqNr: number): VoidResult { private answer(built: Result<{ buffer: Buffer }>, cmdName: string, seqNr: number): VoidResult {
-5
View File
@@ -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 - [ ] **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. 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 - [ ] **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 only in which of the drain, the gate and the window they bypass, and none of the names says
which. Three seats. which. Three seats.