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
14 changed files with 150 additions and 57 deletions
+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.
+6
View File
@@ -74,6 +74,12 @@
`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)`, which returns `err` for a
bind type or version it cannot record. An assignment to either field does not compile in
TypeScript, throws a `TypeError` in strict-mode code (every ES module, and any file under
`'use strict'`), and is ignored otherwise.
## 0.5.0 ## 0.5.0
+5 -1
View File
@@ -13,7 +13,8 @@ shape is the same, connect, send, listen for delivery reports, with callbacks re
a `session` event. It no longer calls back once per connection. 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 - **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. id the segments were answered with, the id `sendResp()` was given, or the generated UUID v7.
Assigning to it throws a `TypeError`, since modules are strict mode. Assigning to it throws a `TypeError` in strict-mode code (every ES module, and any file under
`'use strict'`), and is ignored otherwise.
- **`smsIds` from `sendSms()` is `(string | undefined)[]`**, one entry per segment, positional with - **`smsIds` from `sendSms()` is `(string | undefined)[]`**, one entry per segment, positional with
`pduObjs`, `undefined` where the SMSC took the segment without naming an id. `pduObjs`, `undefined` where the SMSC took the segment without naming an id.
- **`checkuserpass` is `authenticate`**, takes `{ password, session, systemId, systemType }` and - **`checkuserpass` is `authenticate`**, takes `{ password, session, systemId, systemType }` and
@@ -36,6 +37,9 @@ shape is the same, connect, send, listen for delivery reports, with callbacks re
Write `alert_on_message_delivery` and `broadcast_area_identifier`, the names they read back Write `alert_on_message_delivery` and `broadcast_area_identifier`, the names they read back
under, for `alert_on_msg_delivery` and `failed_broadcast_area_identifier`, which are gone from under, for `alert_on_msg_delivery` and `failed_broadcast_area_identifier`, which are gone from
`tlvs` too. `tlvs` too.
- **`session.loggedIn` and the `loggedIn` event are gone.** `client()` resolves once bound,
`session.boundAs !== undefined` says a bind happened, and `disconnected`/`reconnected` say whether
the link is up now. A session you construct yourself records a bind with `session.bound()`.
- **The `error` event is `sessionError`**, and `serverError` on the server handle. - **The `error` event is `sessionError`**, and `serverError` on the server handle.
- **`log`** takes any object with `debug`, `error`, `info`, `verbose` and `warn` methods instead of - **`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). a `larvitutils` one, and is silent by default: [README](README.md#logging).
+14 -2
View File
@@ -421,8 +421,18 @@ 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).
- `boundAs` and `peerInterfaceVersion` are read-only, and hold through a reconnect's gap until the
link binds again.
- `bound(bindType, declaredVersion)`: how a session you construct yourself records a bind, whichever
end accepted it, on every link it binds. `bindType` is `receiver`, `transceiver` or `transmitter`;
`declaredVersion` is 0-255, or `undefined` where the peer declared none. Anything else returns `err`
and records nothing.
- An ESME wired by hand sends its own `bind_<bindType>` through `session.send()`, after it is
constructed and again in `reconnect.onConnected`, and records each accepted one with
`session.bound(bindType, pduObj.tlvs.sc_interface_version?.tagValue)`, where `bindType` is the one
it sent and `pduObj` the `bind_resp` that `send()` resolved with.
## Receiving in depth ## Receiving in depth
@@ -539,7 +549,9 @@ 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(pduObj.cmdName.slice('bind_'.length), pduObj.params.interface_version)` before
answering it, and refuse the bind with `ESME_RBINDFAIL` where that returns `err`.
**`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
+8
View File
@@ -715,6 +715,14 @@ 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()` record their bind through
`bound()`, the call a hand-wired session makes, so the state has one writer — goal 8. Clearing the state at `teardown()` was rejected: `bindAllows()` and
`acceptsOptionalParams()` then answer yes to everything while the link is down, so a
receiver-bound client queues a `submit_sm` the peer refuses and a receipt built then carries TLVs a
pre-3.4 peer must not get — goal 4. Valid while the reconnect loop binds again with the same bind
type to the same peer.
- **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
+1 -1
View File
@@ -62,6 +62,6 @@ describe('smppload (blocked)', () => {
await waitFor(() => (closed ? true : undefined), 5000); await waitFor(() => (closed ? true : undefined), 5000);
assert.equal(closed, true); assert.equal(closed, true);
assert.ok(bound); assert.ok(bound);
assert.equal(bound.loggedIn, false); assert.equal(bound.boundAs, undefined);
}); });
}); });
+4 -7
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,10 @@ 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; const recorded = session.bound(bindType, sent.pduObj.tlvs.sc_interface_version?.tagValue);
if (recorded.err) return recorded;
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 {};
+20 -23
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';
@@ -170,24 +170,12 @@ function bindRespTlvs(session: Session, options: ServerOptions): TlvInputs | und
}; };
} }
async function acceptBind( async function onBind(
session: Session, session: Session,
pduObj: PduObject, pduObj: PduObject,
bindType: BindType,
options: ServerOptions, options: ServerOptions,
identity: Record<string, string>,
): Promise<void> { ): Promise<void> {
const declared = 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));
}
async function onBind(session: Session, pduObj: PduObject, 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 +186,16 @@ async function onBind(session: Session, pduObj: PduObject, options: ServerOption
return; return;
} }
await acceptBind(session, pduObj, options, identity); const recorded = session.bound(bindType, pduObj.params.interface_version);
if (recorded.err) {
session.log.info('server - bind refused', { message: recorded.err.message, systemId });
await session.sendReturn(pduObj, 'ESME_RBINDFAIL', identity);
return;
}
await session.sendReturn(pduObj, 'ESME_ROK', identity, bindRespTlvs(session, options));
session.log.verbose('server - bound', { systemId }); session.log.verbose('server - bound', { systemId });
} }
@@ -211,16 +208,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;
} }
+26 -1
View File
@@ -121,6 +121,31 @@ export const defaultSystemId = '';
/** SMPP 3.4: a peer that declares no version at all is one from before optional parameters. */ /** SMPP 3.4: a peer that declares no version at all is one from before optional parameters. */
export const undeclaredInterfaceVersion = 0x00; 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 = { export const defaults = {
/** Receipts of a multipart message can be a working day apart, so the cap does the bounding. */ /** Receipts of a multipart message can be a working day apart, so the cap does the bounding. */
dlrMergeTimeout: 86_400_000, dlrMergeTimeout: 86_400_000,
@@ -176,7 +201,7 @@ const maxTimerDelay = 2_147_483_647;
function checkConnectTimeout(connectTimeout: unknown): VoidResult { function checkConnectTimeout(connectTimeout: unknown): VoidResult {
if (connectTimeout === undefined || connectTimeout === false) return {}; if (connectTimeout === undefined || connectTimeout === false) return {};
const got = typeof connectTimeout === 'string' ? `"${connectTimeout}"` : namedValue(connectTimeout); const got = quoted(connectTimeout);
if (typeof connectTimeout !== 'number' || !Number.isInteger(connectTimeout) || connectTimeout < 1) { if (typeof connectTimeout !== 'number' || !Number.isInteger(connectTimeout) || connectTimeout < 1) {
return { err: new Error(`connectTimeout must be a whole number of milliseconds, 1 or more, got ${got}; false waits the OS out instead`) }; return { err: new Error(`connectTimeout must be a whole number of milliseconds, 1 or more, got ${got}; false waits the OS out instead`) };
+26 -13
View File
@@ -4,7 +4,7 @@ import type { MessageDlr } from './dlr-merger.ts';
import type { ParamValue } from './defs/types.ts'; import type { ParamValue } from './defs/types.ts';
import type { PduObject, PduObjectInput, TlvInputs } from './pdu.ts'; import type { PduObject, PduObjectInput, TlvInputs } from './pdu.ts';
import type { PduRefusedError } from './pdu-refusal.ts'; import type { PduRefusedError } from './pdu-refusal.ts';
import type { BindType, CloseOptions, LinkEnd, OnRequest, ReconnectOptions, SendOptions, SessionEvents, SessionOptions } from './session-options.ts'; import type { BindType, CloseOptions, LinkEnd, OnRequest, ReconnectOptions, SendOptions, SessionBind, SessionEvents, SessionOptions } from './session-options.ts';
import type { Result, VoidResult } from './result.ts'; import type { Result, VoidResult } from './result.ts';
import type { SendSmsOptions, SendSmsResult } from './send-sms.ts'; import type { SendSmsOptions, SendSmsResult } from './send-sms.ts';
import type { SmppLog } from './log.ts'; import type { SmppLog } from './log.ts';
@@ -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, checkedBind, defaultSystemId, defaults } 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: SessionBind | 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,25 @@ 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. */
bound(bindType: string, declaredVersion: unknown): VoidResult {
const checked = checkedBind(bindType, declaredVersion);
if (!checked.err) this.bind = checked.bind;
return checked.err ? { err: checked.err } : {};
}
/** 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);
@@ -161,8 +177,7 @@ export class Session extends EventEmitter<SessionEvents> {
/** SMPP 3.4 forbids sending optional parameters to a peer that declared an older version. */ /** SMPP 3.4 forbids sending optional parameters to a peer that declared an older version. */
acceptsOptionalParams(): boolean { acceptsOptionalParams(): boolean {
return this.peerInterfaceVersion === undefined return this.peerInterfaceVersion === undefined || this.peerInterfaceVersion >= optionalParamsMinVersion;
|| this.peerInterfaceVersion >= optionalParamsMinVersion;
} }
/** Sends a request and resolves with the peer's response. */ /** Sends a request and resolves with the peer's response. */
@@ -171,15 +186,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 {
+8 -2
View File
@@ -628,6 +628,10 @@ describe('reconnect', () => {
const reconnected = once<true>(resolve => { session.on('reconnected', () => { resolve(true); }); }); const reconnected = once<true>(resolve => { session.on('reconnected', () => { resolve(true); }); });
const halfPdu = once<true>(resolve => { session.on('data', () => { resolve(true); }); }); const halfPdu = once<true>(resolve => { session.on('data', () => { resolve(true); }); });
const boundBefore = [session.boundAs, session.peerInterfaceVersion];
const whileDown = once<unknown[]>(resolve => {
session.on('disconnected', () => { resolve([session.boundAs, session.peerInterfaceVersion]); });
});
// A PDU header promising 32 octets and sending 8: the next link must not continue it. // A PDU header promising 32 octets and sending 8: the next link must not continue it.
peerOf(smpp).sock.write(Buffer.from([0, 0, 0, 32, 0, 0, 0, 4])); peerOf(smpp).sock.write(Buffer.from([0, 0, 0, 32, 0, 0, 0, 4]));
@@ -640,7 +644,9 @@ describe('reconnect', () => {
await reconnected; await reconnected;
assert.ok(session.loggedIn); // The loop rebinds as before, so the gap keeps answering bindAllows() for the bind to come.
assert.deepEqual(await whileDown, boundBefore);
assert.equal(session.boundAs, 'transceiver');
// The session object survives the drop, so listeners stay attached and it is usable again. // The session object survives the drop, so listeners stay attached and it is usable again.
const sent = await session.sendSms({ const sent = await session.sendSms({
@@ -867,7 +873,7 @@ describe('reconnect from the first bind', () => {
assert.equal(err, undefined); assert.equal(err, undefined);
assert.ok(session); assert.ok(session);
assert.equal(session.loggedIn, true); assert.equal(session.boundAs, 'transceiver');
assert.ok(spy.delays.length >= 3, 'the SMSC was down for several attempts'); assert.ok(spy.delays.length >= 3, 'the SMSC was down for several attempts');
assert.deepEqual(spy.delays.slice(0, 3), [10, 20, 40], 'each wait doubles, up to maxDelay'); assert.deepEqual(spy.delays.slice(0, 3), [10, 20, 40], 'each wait doubles, up to maxDelay');
assert.ok( assert.ok(
+30 -1
View File
@@ -229,7 +229,7 @@ describe('bind', () => {
assert.equal(err, undefined); assert.equal(err, undefined);
assert.ok(session); assert.ok(session);
assert.ok(session.loggedIn); assert.equal(session.boundAs, 'transceiver');
assert.deepEqual(await session.unbind(), {}); assert.deepEqual(await session.unbind(), {});
}); });
@@ -448,6 +448,35 @@ describe('bind', () => {
assert.ok(session.acceptsOptionalParams()); assert.ok(session.acceptsOptionalParams());
}); });
test('records a hand-wired bind through bound(), and nothing else writes it', t => {
const session = new Session({ sock: new net.Socket() });
closeAfter(t, session);
assert.equal(session.boundAs, undefined);
assert.equal(session.peerInterfaceVersion, undefined);
assert.deepEqual(session.bound('receiver', 0x34), {});
assert.equal(session.boundAs, 'receiver');
assert.equal(session.peerInterfaceVersion, 0x34);
assert.equal(session.bindAllows('submit_sm'), false);
assert.deepEqual(session.bound('transmitter', undefined), {});
assert.equal(session.peerInterfaceVersion, 0x00, 'no declared version is pre-3.4');
// The likeliest mistakes: the TLV object for its value, a version past int8, the command's name.
assert.match(session.bound('receiver', { tagValue: 0x34 }).err?.message ?? '', /tagValue, or undefined .* got object$/);
assert.ok(session.bound('receiver', 0x100).err);
assert.ok(session.bound('receiver', 3.4).err);
assert.match(session.bound('bind_receiver', 0x34).err?.message ?? '', /without "bind_", got "bind_receiver"$/);
assert.equal(session.boundAs, 'transmitter', 'a refused bind leaves the recorded one alone');
assert.equal(session.peerInterfaceVersion, 0x00);
assert.equal(Reflect.set(session, 'boundAs', 'transceiver'), false);
assert.equal(Reflect.set(session, 'peerInterfaceVersion', 0x50), false);
assert.equal(session.boundAs, 'transmitter');
assert.equal('loggedIn' in session, false);
});
// The spec: an absent sc_interface_version means the SMSC supports no optional parameters. // The spec: an absent sc_interface_version means the SMSC supports no optional parameters.
test('takes an SMSC that declares no version as older than 3.4', async t => { test('takes an SMSC that declares no version as older than 3.4', async t => {
const peer = await smscPeer(t); const peer = await smscPeer(t);
+1 -1
View File
@@ -135,7 +135,7 @@ describe('tls', () => {
assert.equal(err, undefined); assert.equal(err, undefined);
assert.ok(session); assert.ok(session);
assert.ok(session.loggedIn); assert.equal(session.boundAs, 'transceiver');
closeAfter(t, session); closeAfter(t, session);
const sock = session.sock; const sock = session.sock;
-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.