diff --git a/AGENTS.md b/AGENTS.md index 8ed8839..882bb24 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,5 @@ # AGENTS.md -Guidance for LLM agents working in this repository. What each file in it is for is under -[Documentation](#documentation). - ## What this is A ground-up TypeScript rewrite of `larvitsmpp` 0.4.0, published as `@larvit/smpp` 0.5.0. The branch @@ -13,7 +10,7 @@ not for structure or style. ## Goals The goals, in priority order, live in -[README.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/README.md#goals). The README states the audience alongside them. +[README.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/README.md#goals). ## Hard rules @@ -50,7 +47,7 @@ src/ dlr-merger.ts DlrMerger: per-segment receipts counted into one MessageDlr 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: capped, expiring messages the application has not answered, one MessageHold each + 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 @@ -87,8 +84,8 @@ src/ 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()` and `IncomingRequests`, which call back into it, and to `OnRequest` and `onConnected` -in `session-options.ts`, all imported as a type only. +`createSms()`, `HeldMessages` and `IncomingRequests`, which call back into it, and to `OnRequest` +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 @@ -225,10 +222,6 @@ this is not a changelog. ## Decisions -The decisions themselves live in [docs/decisions.md](docs/decisions.md). Their titles are indexed -here, so a reader sees that a decision exists without carrying its reasoning; the reasoning is in -the file. - ### [The public surface](docs/decisions.md#the-public-surface) - `Session` is publicly constructible, which is what makes `SessionOptions` and `ReconnectOptions` @@ -315,7 +308,7 @@ the file. ### [Internals and tests](docs/decisions.md#internals-and-tests) -- #30, #46 and #48 merged under the comprehension floor, and Locality is the next work. +- 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 `SendWindow` rather than extracted. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2847a9c..7cb5ff7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,8 @@ `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. +- The `SmsInput` type is no longer exported; nothing exported took one. Annotate with `Sms`, or a + `Pick` of the fields you use. - `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 diff --git a/README.md b/README.md index a599cea..9ff2b7d 100644 --- a/README.md +++ b/README.md @@ -713,10 +713,9 @@ one wins. They do not override the [hard rules](https://gitea.larvit.se/larvit/s alphabet, a receipt format — it gets an option or a hook rather than a fork. A call that passes no options stays exactly as easy and as safe, and a hook is a seam the library calls, never a way into its internals. -8. **A small, stable public surface over reshapeable internals.** Only what `src/index.ts` exports is - published. A new option has to beat "the application can do this itself", and has to keep a - promise this library can verify. The low-level surface is a passthrough: policy binds what the - library composes, never what the caller wrote. +8. **A small, stable public surface over reshapeable internals.** A new option has to beat "the + application can do this itself", and has to keep a promise this library can verify. The low-level + surface is a passthrough: policy binds what the library composes, never what the caller wrote. 9. **State wider than one session goes through one store.** A pool of sessions, a limit shared between processes, and what has to survive a restart — receipts still awaited, a message half reassembled — are held through a store interface and never beside it. Without a store the diff --git a/docs/decisions.md b/docs/decisions.md index 7ecd0a3..c245097 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -8,8 +8,7 @@ rule and an index of the titles below. - **`Session` is publicly constructible, which is what makes `SessionOptions` and `ReconnectOptions` public too.** Raised twice as a leak; it is not one. The collaborators `session.ts` delegates to - (`IncomingRequests`, `OutgoingRequests`, `ReconnectLoop`, `LinkTimers`, `DlrMerger`, - `PduTransport`, `submitSms`) stay unpublished so they can be reshaped. + stay unpublished so they can be reshaped. - **`acceptsOptionalParams()` and `bindAllows()` are predicates, not chokepoints.** The library's own senders consult them; `session.send({ tlvs })` is passed through as written, because silently @@ -418,8 +417,7 @@ rule and an index of the titles below. caller would get wrong, where asking the codec is, and publishing it would freeze this library's error prose as API for an application whose own refusal should read like itself. Goal 8, from the architecture review of [#99](https://github.com/larvit/larvitsmpp/pull/99), 2026-09-09. It is - reached through - `encodeBody()` in `message.ts`, which is where the `data_coding`-to-text pair already lives: + reached through `encodeBody()` in `message.ts`, which is where the `data_coding`-to-text pair already lives: `encodeBody(text, dataCoding)` is `decodeMessage(buffer, dataCoding)`'s mirror and resolves the alphabet through the same `encodingByDataCoding()`. `send()` and `sendReturn()` inherit it, since both build through `buildPdu()`; `sendSms()` does not, and keeps its own guard, because @@ -641,8 +639,7 @@ rule and an index of the titles below. failure belongs to one session's request, and that channel already carries every failure of one. The hook is consulted before the bind-direction gate, so it sees a `submit_sm` a receiver-bound peer may not send; first refusal means first, and one it declines still gets `ESME_RINVBNDSTS`. - Nothing is held for a request the hook answered: `HeldMessages` is opened by the `sms` event the - hook skipped, so the drain waits on none of it. `OnRequest` stays unexported where + Nothing is held for a request the hook answered, so the drain waits on none of it. `OnRequest` stays unexported where `AuthenticateInput` is exported, because that hook's argument is a shape this library invents and this one's are two types already published. Rejected: consulting the hook first, which puts bind and authentication inside the application's reach for nothing. Rejected: a narrower hook @@ -765,15 +762,11 @@ rule and an index of the titles below. ## Internals and tests -- **#30, #46 and #48 merged under the comprehension floor, and Locality is the next work.** Maintainer's call, - 2026-09-27. A four-seat scoring run, depth 1, read the project at 6, 6, 7 and 6 (mean 6.25), every - seat capped by Locality in the held-message and shutdown code #30 does not touch, where the floor - is 7.0. The chunks after #30 lift Locality to 7 before any other work. Serves goal 8's - reshapeable internals, which a reader has to understand before reshaping. Valid until a scoring - run reads 7.0 or above. #46, maintainer's call 2026-09-28, merged as a step of that work at 6, 6, 7 - and 7, Locality 5, 5, 6 and 6, up from 6, 6, 7 and 6 and Locality 5, 5, 6 and 5 the same day. - #48, maintainer's call 2026-09-28, merged at 6, 6, 6 and 6, Locality 5 from every seat, on the - condition that the held-message flow is the next chunk. +- **Locality work comes before other work until a scoring run reads 7.0.** Maintainer's call, + 2026-09-27, when #30 merged under the comprehension floor at 6, 6, 7 and 6; #46, #48 and #49 + merged under it on that condition, #49 at 6, 6, 7 and 6 with Locality 5, 5, 6 and 6. Serves goal + 8's reshapeable internals, which a reader has to understand before reshaping. Valid until a + scoring run reads 7.0 or above. - **A listener that rejects is routed by Node's `captureRejections`, not by hand-dispatching.** Both emitters construct with `captureRejections: true` and implement diff --git a/src/held-messages.ts b/src/held-messages.ts index 4ae9e09..b9e740e 100644 --- a/src/held-messages.ts +++ b/src/held-messages.ts @@ -1,15 +1,23 @@ -import type { PduObject } from './pdu.ts'; +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; }; @@ -20,33 +28,40 @@ function keyOf(pduObjs: PduObject[]): string | undefined { return first ? String(first.seqNr) : undefined; } -type HoldEntry = { - isHeld: () => boolean; - release: () => void; -}; +type HoldRoute = Pick; /** - * One message offered to the application. 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 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 { - private readonly entry: HoldEntry; +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(entry: HoldEntry, listeners: number) { - this.entry = entry; + 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.entry.isHeld(); + return this.heldMessages.holds(this.pduObjs); } - /** A turn later, so a listener sending its receipt straight after the response still holds. */ + /** A turn later, so a `sendDlr()` called straight after `sendResp()` still goes out past a drain. */ answered(): void { - setImmediate(() => { this.entry.release(); }); + 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. */ @@ -58,7 +73,12 @@ export class MessageHold { /** At once, for a message nobody took or a listener threw on: that is not work a shutdown can wait for. */ release(): void { - this.entry.release(); + 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); } } @@ -70,6 +90,7 @@ export class HeldMessages { 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({ @@ -80,6 +101,7 @@ export class HeldMessages { }); this.log = options.log; this.maxOctets = options.maxOctets; + this.route = { link: options.link, sendPastDrain: options.sendPastDrain, session: options.session }; } get octetsHeld(): number { @@ -97,14 +119,8 @@ export class HeldMessages { return this.held.full || this.held.weight >= this.maxOctets; } - private hold(pduObjs: PduObject[], listeners: number): MessageHold { - const hold = new MessageHold({ - isHeld: () => this.has(pduObjs), - release: () => { this.release(pduObjs); }, - }, listeners); - const key = keyOf(pduObjs); - - if (key === undefined) return hold; + private hold(key: string, pduObjs: PduObject[], listeners: number): MessageHold { + const hold = new MessageHold(this, this.route, pduObjs, listeners); this.sweep(); @@ -118,18 +134,17 @@ export class HeldMessages { return hold; } - offer( - pduObjs: PduObject[], - listeners: number, - build: (hold: MessageHold) => T, - emit: (message: T) => boolean, - ): MessageHold { - const hold = this.hold(pduObjs, listeners); - const message = build(hold); + offer(pduObjs: PduObject[], answeredAs?: string): MessageHold | undefined { + const key = keyOf(pduObjs); - this.offered.set(message, hold); + if (key === undefined) return undefined; - if (!emit(message)) hold.release(); + 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; } @@ -141,13 +156,13 @@ export class HeldMessages { this.offered.get(message)?.listenerGaveUp(); } - private has(pduObjs: PduObject[]): boolean { + holds(pduObjs: PduObject[]): boolean { const key = keyOf(pduObjs); return key !== undefined && this.held.get(key) === pduObjs; } - private release(pduObjs: PduObject[]): void { + release(pduObjs: PduObject[]): void { const key = keyOf(pduObjs); // Identity, not the key: a wrapped sequence number must not release someone else's message. diff --git a/src/incoming-requests.ts b/src/incoming-requests.ts index f2a39f3..51aeec7 100644 --- a/src/incoming-requests.ts +++ b/src/incoming-requests.ts @@ -1,22 +1,21 @@ 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, PduObjectInput } from './pdu.ts'; -import type { Result, VoidResult } from './result.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, decodeSegments } from './reassembly.ts'; +import { Reassembler } from './reassembly.ts'; import { bindCommands, defaults, standsInFor } from './session-options.ts'; import { concatOf } from './concat.ts'; -import { createSms } from './sms.ts'; import { detach } from './retained-pdu.ts'; import { dlrFromPdu } from './dlr.ts'; -import { paramText } from './defs/types.ts'; import { respIdParams, segmentId } from './sms-id.ts'; import { respNameFor } from './defs/commands.ts'; @@ -52,8 +51,7 @@ export type IncomingRequestsOptions = { maxReassembly?: number | undefined; onRequest?: OnRequest | undefined; reassemblyTimeout?: number | undefined; - /** Past a drain's refusal, for a receipt the drain is itself waiting for. */ - sendPastDrain: (input: PduObjectInput) => Promise>; + sendPastDrain: HeldMessagesOptions['sendPastDrain']; session: Session; smsIdFormat?: SmsIdFormat | undefined; systemId?: string | undefined; @@ -67,7 +65,6 @@ export class IncomingRequests { private readonly log: SmppLog; private readonly onRequest: OnRequest | undefined; private readonly reassembler: Reassembler; - private readonly sendPastDrain: IncomingRequestsOptions['sendPastDrain']; private readonly session: Session; private readonly smsIdFormat: SmsIdFormat; private readonly systemId: string; @@ -76,9 +73,12 @@ export class IncomingRequests { 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; @@ -91,7 +91,6 @@ export class IncomingRequests { onLost: lost => { this.reportLost(lost); }, timeout: options.reassemblyTimeout ?? defaults.reassemblyTimeout, }); - this.sendPastDrain = options.sendPastDrain; this.session = options.session; this.smsIdFormat = options.smsIdFormat ?? {}; this.systemId = options.systemId ?? defaults.systemId; @@ -254,7 +253,7 @@ export class IncomingRequests { const concat = concatOf(pduObj); if (!concat) { - this.emitSms([detach(pduObj)]); + this.held.offer([detach(pduObj)]); return; } @@ -276,7 +275,7 @@ export class IncomingRequests { respIdParams(pduObj.cmdName, segmentId(collected.smsId, concat.part - 1, concat.total)), ); - if (collected.whole) this.emitSms(collected.whole, collected.smsId); + if (collected.whole) this.held.offer(collected.whole, collected.smsId); } private reportLost(lost: LostGroup): void { @@ -284,25 +283,4 @@ export class IncomingRequests { `Gave up ${String(lost.parts)} of ${String(lost.total)} segments of an incomplete concatenated message: ${lostReasons[lost.reason]}`, )); } - - private emitSms(pduObjs: PduObject[], answeredAs?: string): void { - const first = pduObjs[0]; - - if (!first) return; - - const generation = this.link.generation(); - - this.held.offer(pduObjs, this.session.listenerCount('sms'), hold => createSms({ - answeredAs, - from: paramText(first.params.source_addr), - message: decodeSegments(pduObjs), - pduObjs, - session: this.session, - to: paramText(first.params.destination_addr), - }, { - lostLink: () => this.link.generation() !== generation, - onAnswered: () => { hold.answered(); }, - send: input => (hold.isHeld() ? this.sendPastDrain(input) : this.session.send(input)), - }), sms => this.session.emit('sms', sms)); - } } diff --git a/src/index.ts b/src/index.ts index 509250a..71fa973 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,7 +38,7 @@ export { uuidv7 } from './uuid.ts'; export type { BindType, ClientOptions } from './client.ts'; export type { Dlr, Receipt } from './dlr.ts'; -export type { SendDlrResult, SendRespOptions, Sms, SmsInput } from './sms.ts'; +export type { SendDlrResult, SendRespOptions, Sms } from './sms.ts'; export type { Concat } from './concat.ts'; export type { ConcatInfo } from './udh.ts'; export type { Result, VoidResult } from './result.ts'; diff --git a/src/sms.ts b/src/sms.ts index 93f75c7..5149ceb 100644 --- a/src/sms.ts +++ b/src/sms.ts @@ -5,7 +5,9 @@ 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'; @@ -59,17 +61,13 @@ export type Sms = { export type SmsInput = { /** The id base the segments were already answered with; absent leaves the answer to `sendResp()`. */ answeredAs?: string | undefined; - from: string; - message: string; pduObjs: PduObject[]; session: Session; - to: string; }; -/** What the session's incoming side gives a message so it can be answered and accounted for. */ export type SmsHandlers = { + answered: () => void; lostLink: () => boolean; - onAnswered: () => void; send: (input: PduObjectInput) => Promise>; }; @@ -86,8 +84,8 @@ export function createSms(input: SmsInput, handlers: SmsHandlers): Sms { answeredOnArrival: input.answeredAs !== undefined, dlr: typeof registered === 'number' && registered !== 0, flash: typeof dataCoding === 'number' && messageClassOf(dataCoding) === immediateDisplayClass, - from: input.from, - message: input.message, + 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 @@ -98,7 +96,7 @@ export function createSms(input: SmsInput, handlers: SmsHandlers): Sms { return answered.smsId; }, submitTime: new Date(), - to: input.to, + to: paramText(first?.params.destination_addr), }; return sms; @@ -107,7 +105,7 @@ export function createSms(input: SmsInput, handlers: SmsHandlers): Sms { /** Every segment went out answered, so the call is what the shutdown waits for and nothing else. */ function answeredOnArrival( options: SendRespOptions, - handlers: Pick, + handlers: Pick, ): Promise { if (options.smsId !== undefined) { return Promise.resolve({ @@ -121,7 +119,7 @@ function answeredOnArrival( }); } - handlers.onAnswered(); + handlers.answered(); return Promise.resolve({}); } @@ -131,7 +129,7 @@ async function sendResp( session: Session, answered: { smsId: string }, options: SendRespOptions, - handlers: Pick, + handlers: Pick, ): Promise { const total = sms.pduObjs.length; @@ -158,7 +156,7 @@ async function sendResp( const failure = results.find(result => result.err); - if (!failure) handlers.onAnswered(); + if (!failure) handlers.answered(); return failure ?? {}; } diff --git a/test/session-extras.test.ts b/test/session-extras.test.ts index a6b75a1..6b36279 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 { MessageHold } from '../src/held-messages.ts'; +import type { HeldMessagesOptions, MessageHold } 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'; @@ -1543,11 +1543,34 @@ describe('held message bounds', () => { } function offer(held: HeldMessages, seqNr: number): MessageHold { - return held.offer(message(seqNr), 1, () => ({}), () => true); + const hold = held.offer(message(seqNr)); + + assert.ok(hold); + + return hold; } - test('is full at its count, and a re-used sequence number replaces rather than adding', () => { - const held = new HeldMessages({ log: silentLog, max: 2, maxOctets: 1_000_000, timeout: 10_000 }); + /** Offers to a session with a listener, so an offer is held rather than released as untaken. */ + function heldOn( + t: TestContext, + options: Pick, + ): HeldMessages { + const session = new Session({ sock: new net.Socket() }); + + closeAfter(t, session); + session.on('sms', () => undefined); + + return new HeldMessages({ + ...options, + link: new LinkLife({ log: silentLog, reconnects: false, timeout: 100 }), + log: silentLog, + sendPastDrain: () => Promise.resolve({ err: new Error('never sent') }), + session, + }); + } + + test('is full at its count, and a re-used sequence number replaces rather than adding', t => { + const held = heldOn(t, { max: 2, maxOctets: 1_000_000, timeout: 10_000 }); const first = offer(held, 1); const replaced = offer(held, 2); @@ -1563,9 +1586,9 @@ describe('held message bounds', () => { }); // 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', () => { + test('is full at its octet cap, until a message leaves by any way out', t => { let now = 0; - const held = new HeldMessages({ log: silentLog, max: 10, maxOctets: 2000, now: () => now, timeout: 10_000 }); + const held = heldOn(t, { max: 10, maxOctets: 2000, now: () => now, timeout: 10_000 }); const answered = offer(held, 1); assert.equal(held.full(), false); @@ -1658,9 +1681,9 @@ describe('held message bounds', () => { incoming.clear(); }); - test('gives up on a message the application never answers', () => { + test('gives up on a message the application never answers', t => { let now = 0; - const held = new HeldMessages({ log: silentLog, max: 10, maxOctets: 1_000_000, now: () => now, timeout: 60 }); + const held = heldOn(t, { max: 10, maxOctets: 1_000_000, now: () => now, timeout: 60 }); offer(held, 1); now = 61; @@ -1674,9 +1697,9 @@ describe('held message bounds', () => { }); // Without this the drain sits out its whole budget before returning what a sweep already settled. - test('wakes a waiting drain when the last message expires', async () => { + test('wakes a waiting drain when the last message expires', async t => { let now = 0; - const held = new HeldMessages({ log: silentLog, max: 10, maxOctets: 1_000_000, now: () => now, timeout: 60 }); + const held = heldOn(t, { max: 10, maxOctets: 1_000_000, now: () => now, timeout: 60 }); offer(held, 1); @@ -1701,14 +1724,11 @@ describe('sendResp()', () => { session.sendReturn = () => Promise.resolve({ err: new Error('Socket is closed') }); const sms = createSms({ - from: '46701113311', - message: 'never answered', pduObjs: [submitPdu(1)], session, - to: '46709771337', }, { + answered: () => { answered++; }, lostLink: () => false, - onAnswered: () => { answered++; }, send: () => Promise.resolve({ err: new Error('never sent') }), }); @@ -1726,14 +1746,11 @@ describe('sendDlr()', () => { let call = 0; const sms = createSms({ - from: '46701113311', - message: 'three segments', pduObjs: [submitPdu(1), submitPdu(2), submitPdu(3)], session, - to: '46709771337', }, { + answered: () => undefined, lostLink: () => false, - onAnswered: () => undefined, send: () => { call++; diff --git a/todo.md b/todo.md index 6406b80..3e5b9c7 100644 --- a/todo.md +++ b/todo.md @@ -205,17 +205,14 @@ after #46, read 6, 6, 7 and 7, Locality 5, 5, 6 and 6. A fourth, after the link' owner in #48, read 6, 6, 6 and 6, Locality 5 from every seat: all four still ranked `Session.teardown()` hardest, and the held-message flow across `incoming-requests.ts`, `held-messages.ts`, `sms.ts` and `Session`'s rejection handler second. +A fifth, after the held-message flow got one owner, read 6, 6, 7 and 6, Locality 5, 5, 6 and 6: +three seats still ranked `MessageHold` hardest — six ways out, a rejection routed from `Session` +through `IncomingRequests` to a `WeakMap`, and the `setImmediate` a receipt relies on — and the +teardown cluster second; the inherited architect scored Shape 5 on the flat `src/` and the names +below. - [ ] **Lift Locality to 7, and confirm it with a scoring run.** A run reading 7.0 or above also - retires the #30, #46 and #48 decision. - -- [ ] **Give the held-message flow one owner — next, the condition #48 merged under.** Whether a - message is still held, and so whether its receipt may pass the drain, is decided across - `IncomingRequests.emitSms()`, `HeldMessages.offer()`/`MessageHold`, `createSms()`'s handlers in - `sms.ts` and `Session`'s rejection handler, which finds the hold again through a `WeakMap` - keyed on the `Sms`; `MessageHold.answered()` defers its release a `setImmediate` so a - `sendDlr()` straight after `sendResp()` still counts as held. All four seats of the #48 run - ranked it second hardest; the inherited architect put it at about two days. + retires the Locality-first decision. ### Correctness @@ -325,10 +322,16 @@ hardest, and the held-message flow across `incoming-requests.ts`, `held-messages - [ ] **Collapse the three objects named `defaults`.** `client.ts`, `server.ts` and `session-options.ts` each export or hold one; `port: 2775` is written twice and the idle - timeout is derived two ways to the same 40 000. "What is the default for X" has three answers + timeout is derived two ways to the same 40 000, and 64 MiB is both `defaultMaxOctets` and + `defaults.maxHeldOctets`. "What is the default for X" has three answers depending on the entrypoint, and nothing fails when they drift. Named by both architects as the most likely first bug a new contributor ships. +- [ ] **Give `hold` one meaning, and rename `IncomingRequests.refusing` for what it does.** + `LinkLife.hold()` is a request's budget waiting for a link, `HeldMessages.hold()` a message the + application owes an answer; `refusing` decides no refusal — `held.full()` does — and only makes + the warn and info lines fire once each way. From the 2026-09-29 scoring run. + - [ ] **Rename `EncodingName`'s `ASCII` to `GSM7`, with `ASCII` a deprecated alias for one minor.** It is GSM 03.38, where `$` is 0x02 and `@` is 0x00, and `segmentUnits.ASCII = 153` is a septet budget under a name that says octets. The 2026-09-09 decision removed `consts.ENCODING.ASCII` @@ -349,6 +352,12 @@ hardest, and the held-message flow across `incoming-requests.ts`, `held-messages ### Self-sufficiency — 6–7 today, and the gate is 7 +- [ ] **Move the fixture-copy reasoning out of AGENTS.md's Conventions, and split the longest + decision entries.** The fixtures bullet holds four justifications for tolerated copies — decisions, + so they belong in `docs/decisions.md` under Internals and tests; the entries under the wire's + alphabet and body rules run 30–40 lines with their `Rejected:` clauses inline, where a reader + who knows the answer still hunts for it. From the 2026-09-29 prose pass. + - [ ] **Move the one-line facts out of the decision log and back to the code.** Five of nine readers independently reported being sent to `docs/decisions.md` for a question they hit while reading, with no link from the code; one counted roughly fifty index redirects. The three left to inline