diff --git a/docs/decisions.md b/docs/decisions.md index 425fe66..6c2247e 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -577,18 +577,14 @@ rule and an index of the titles below. - **A deliberate shutdown drains; an unusable link and an abort do not.** `close()` and `unbind()` wait on the send window rather than 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, and empties on a drop too, where `teardown()` settles everything the link was - carrying, which is why `drain()` reads `closed` before it reads the count. 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, so draining any of the - three would only hold a socket open for the timeout. `unbind()` sends its own PDU through - `request()` past both the window and the drain gate, because it must go out either way. - `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. `SmppServer.close()` - reports each session's unfinished drain through `serverError`, because its own result says nothing - but that the listener stopped. + a full window, and finishing a half-sent multipart message is the point. A stream the framer or + the codec cannot read, an aborted `close({ signal })` and a peer's own `unbind` do not drain: + 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, so draining any of the three would only hold a socket + open for the timeout. `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. + `SmppServer.close()` reports each session's unfinished drain through `serverError`, because its + own result says nothing but that the listener stopped. - **`sendSms()` puts every segment of a message on the wire together.** Goal 6: a long message costs one round trip rather than one per segment. Rejected: sending each segment once the last is @@ -765,16 +761,14 @@ rule and an index of the titles below. `window.idle()`, and `unbind()` taking none is the shape README states. - **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`. `LinkGate` owns the answer instead — `shut(returning)` - on every teardown, `open()` only once `comeBackUp()` has a bound link — and - `OutgoingRequests.canCarry()` reads it rather than `closed`. The gate is told what happened and + Maintainer's call, 2026-09-01: `attach()` marks the session attached the moment a socket is + handed over, one round trip before the bind is answered, so gating on that let a send arriving in + that window go out unbound and come back `ESME_RINVBNDSTS`. 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. The retry in `carry()` asks `gate.awaitsNextLink()` rather than `canCarry()`, which also - reads the socket: a loop condition the gate does not gate on spins against a gate that admits it - straight back. + release. The retry in `requestPastDrain()` asks `gate.awaitsNextLink()` rather than `canCarry()`, + which also reads the socket: a loop condition the gate does not gate on spins against a gate that + admits it straight back. ## Internals and tests diff --git a/src/outgoing-requests.ts b/src/outgoing-requests.ts index 2193f4a..f2c92f9 100644 --- a/src/outgoing-requests.ts +++ b/src/outgoing-requests.ts @@ -93,11 +93,11 @@ export class OutgoingRequests { return Promise.resolve({ err: new Error('Session is shutting down') }); } - return this.carry(input, options); + return this.requestPastDrain(input, options); } - /** The same path without the drain's refusal, which a receipt for a held message has to take. */ - async carry( + /** request() without the drain's refusal, which a receipt for a held message has to take. */ + async requestPastDrain( input: PduObjectInput, options: SendOptions, ): Promise> { @@ -109,7 +109,7 @@ export class OutgoingRequests { if (bindCommands.includes(input.cmdName)) { const shut = this.gate.refusal(); - return shut ? { err: shut } : this.now(input, options); + return shut ? { err: shut } : this.requestPastDrainGateAndWindow(input, options); } const waitForLink = this.gate.hold(options.signal); @@ -129,8 +129,11 @@ export class OutgoingRequests { } } - /** Past the gate, the window and a drain, for what has to go out either way. */ - async now(input: PduObjectInput, options: SendOptions = {}): Promise> { + /** Straight onto the current link, for what has to go out either way. */ + async requestPastDrainGateAndWindow( + input: PduObjectInput, + options: SendOptions = {}, + ): Promise> { return (await this.attempt(input, options)).result; } diff --git a/src/session.ts b/src/session.ts index b165697..59ee036 100644 --- a/src/session.ts +++ b/src/session.ts @@ -235,9 +235,8 @@ export class Session extends EventEmitter { async unbind(): Promise { const drained = await this.drain(undefined); const wasOpen = this.lifecycle === 'attached'; - // now(), not send(): a drain refuses a send, and the unbind goes out either way. const sent = wasOpen - ? await this.outgoing.now({ cmdName: 'unbind' }) + ? await this.outgoing.requestPastDrainGateAndWindow({ cmdName: 'unbind' }) : { err: new Error('Session is closed') }; const closedOnUnbind = wasOpen && this.lifecycle !== 'attached'; @@ -274,7 +273,7 @@ export class Session extends EventEmitter { reportError: err => { this.emit('sessionError', err); }, reportMessageDlr: merged => { this.emit('messageDlr', merged); }, send: input => this.send(input), - sendPastDrain: input => this.outgoing.carry(input, {}), + sendPastDrain: input => this.outgoing.requestPastDrain(input, {}), smsListeners: () => this.listenerCount('sms'), }; } diff --git a/todo.md b/todo.md index ff08643..4602348 100644 --- a/todo.md +++ b/todo.md @@ -204,9 +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. -- [ ] **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. - [ ] **Give the held-message flow one place a reader can follow it.** Whether a drain still waits on a message is spread over `emitSms()`, `MessageHold`, the session's rejection route and `Sms.isHeld()`. Four seats. @@ -340,6 +337,10 @@ and 5. Every seat ranked the session's lifecycle hardest and least wanted to mod option"). A sender with more than 1000 concurrent multipart `dlr: true` messages silently evicts the oldest at `warn`. The inherited architect hit this on the 3am walk. +- [ ] **State in README that `SmppServer.close()` reports each session's unfinished drain as + `serverError`.** Only `docs/decisions.md` says so; README's Shutdown section covers the + session's own result alone. + - [ ] **Add a ten-line SMPP glossary to the README.** Both juniors and the no-domain mid reported the same largest cost: nothing in the repo says what a PDU, `esm_class`, `data_coding`, TON/NPI or `submit_sm`-versus-`deliver_sm` are, and the inline spec citations mark a rule without