Compare commits

4 Commits

Author SHA1 Message Date
lilleman b1023f3d72 File the server close's serverError as a README gap
Mirror / push (push) Successful in 4s
Test / lint (pull_request) Successful in 23s
Test / test (18) (pull_request) Successful in 32s
Test / test (20) (pull_request) Successful in 31s
Test / test (22) (pull_request) Successful in 31s
Test / test (24) (pull_request) Successful in 32s
Test / test (26) (pull_request) Successful in 32s
2026-09-28 10:29:38 +02:00
lilleman a9fcf90e86 Drop the drain and gate decisions' stale closed flag and what the code already says
Mirror / push (push) Has been cancelled
Test / lint (pull_request) Successful in 24s
Test / test (22) (pull_request) Successful in 32s
Test / test (18) (pull_request) Successful in 31s
Test / test (20) (pull_request) Successful in 31s
Test / test (24) (pull_request) Successful in 32s
Test / test (26) (pull_request) Successful in 32s
2026-09-28 10:29:32 +02:00
lilleman eceae2781a Name the unbind's path in the drain decision, and drop a request() doc a bind falsifies
Mirror / push (push) Successful in 6s
Test / lint (pull_request) Successful in 24s
Test / test (18) (pull_request) Successful in 32s
Test / test (20) (pull_request) Successful in 31s
Test / test (24) (pull_request) Successful in 32s
Test / test (22) (pull_request) Successful in 31s
Test / test (26) (pull_request) Successful in 32s
2026-09-28 10:28:15 +02:00
lilleman b4bb531200 Name what each of the three request paths skips
Test / test (22) (pull_request) Successful in 32s
Test / test (24) (pull_request) Successful in 32s
Mirror / push (push) Has been cancelled
Test / test (26) (pull_request) Successful in 32s
Test / lint (pull_request) Successful in 23s
Test / test (18) (pull_request) Successful in 32s
Test / test (20) (pull_request) Successful in 32s
2026-09-28 10:25:08 +02:00
4 changed files with 29 additions and 32 deletions
+14 -20
View File
@@ -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()` - **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 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, a full window, and finishing a half-sent multipart message is the point. A stream the framer or
never outcomes, and empties on a drop too, where `teardown()` settles everything the link was the codec cannot read, an aborted `close({ signal })` and a peer's own `unbind` do not drain:
carrying, which is why `drain()` reads `closed` before it reads the count. A stream the framer or nothing on a dead link can answer, an abort means stop now, and a peer that has declared itself
the codec cannot read takes `teardown()` instead, and `close({ signal })` on an aborted signal and finished will not answer what it still owes, so draining any of the three would only hold a socket
a peer's own `unbind` take `end()`: nothing on a dead link can answer, an abort means stop now, and open for the timeout. `shutdownTimeout` stays a session option rather than a `close()` argument:
a peer that has declared itself finished will not answer what it still owes, so draining any of the `server()` builds sessions on the caller's behalf, so the option is the only composition point.
three would only hold a socket open for the timeout. `unbind()` sends its own PDU through `SmppServer.close()` reports each session's unfinished drain through `serverError`, because its
`request()` past both the window and the drain gate, because it must go out either way. own result says nothing but that the listener stopped.
`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 - **`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 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. `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.** - **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 Maintainer's call, 2026-09-01: `attach()` marks the session attached the moment a socket is
round trip before the bind is answered, so gating on `closed` let a send arriving in that window go handed over, one round trip before the bind is answered, so gating on that let a send arriving in
out unbound and come back `ESME_RINVBNDSTS`. `LinkGate` owns the answer instead — `shut(returning)` that window go out unbound and come back `ESME_RINVBNDSTS`. The gate is told what happened and
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
never reads back into the session: a collaborator that has to ask does not own its decision, which 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 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 release. The retry in `requestPastDrain()` asks `gate.awaitsNextLink()` rather than `canCarry()`,
reads the socket: a loop condition the gate does not gate on spins against a gate that admits it which also reads the socket: a loop condition the gate does not gate on spins against a gate that
straight back. admits it straight back.
## Internals and tests ## Internals and tests
+9 -6
View File
@@ -93,11 +93,11 @@ export class OutgoingRequests {
return Promise.resolve({ err: new Error('Session is shutting down') }); 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. */ /** request() without the drain's refusal, which a receipt for a held message has to take. */
async carry( async requestPastDrain(
input: PduObjectInput, input: PduObjectInput,
options: SendOptions, options: SendOptions,
): Promise<Result<{ pduObj: PduObject }>> { ): Promise<Result<{ pduObj: PduObject }>> {
@@ -109,7 +109,7 @@ export class OutgoingRequests {
if (bindCommands.includes(input.cmdName)) { if (bindCommands.includes(input.cmdName)) {
const shut = this.gate.refusal(); 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); 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. */ /** Straight onto the current link, for what has to go out either way. */
async now(input: PduObjectInput, options: SendOptions = {}): Promise<Result<{ pduObj: PduObject }>> { async requestPastDrainGateAndWindow(
input: PduObjectInput,
options: SendOptions = {},
): Promise<Result<{ pduObj: PduObject }>> {
return (await this.attempt(input, options)).result; return (await this.attempt(input, options)).result;
} }
+2 -3
View File
@@ -235,9 +235,8 @@ export class Session extends EventEmitter<SessionEvents> {
async unbind(): Promise<VoidResult> { async unbind(): Promise<VoidResult> {
const drained = await this.drain(undefined); const drained = await this.drain(undefined);
const wasOpen = this.lifecycle === 'attached'; const wasOpen = this.lifecycle === 'attached';
// now(), not send(): a drain refuses a send, and the unbind goes out either way.
const sent = wasOpen const sent = wasOpen
? await this.outgoing.now({ cmdName: 'unbind' }) ? await this.outgoing.requestPastDrainGateAndWindow({ cmdName: 'unbind' })
: { err: new Error('Session is closed') }; : { err: new Error('Session is closed') };
const closedOnUnbind = wasOpen && this.lifecycle !== 'attached'; const closedOnUnbind = wasOpen && this.lifecycle !== 'attached';
@@ -274,7 +273,7 @@ export class Session extends EventEmitter<SessionEvents> {
reportError: err => { this.emit('sessionError', err); }, reportError: err => { this.emit('sessionError', err); },
reportMessageDlr: merged => { this.emit('messageDlr', merged); }, reportMessageDlr: merged => { this.emit('messageDlr', merged); },
send: input => this.send(input), send: input => this.send(input),
sendPastDrain: input => this.outgoing.carry(input, {}), sendPastDrain: input => this.outgoing.requestPastDrain(input, {}),
smsListeners: () => this.listenerCount('sms'), smsListeners: () => this.listenerCount('sms'),
}; };
} }
+4 -3
View File
@@ -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 - [ ] **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.
- [ ] **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 - [ ] **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 on a message is spread over `emitSms()`, `MessageHold`, the session's rejection route and
`Sms.isHeld()`. Four seats. `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 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. 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 - [ ] **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 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 or `submit_sm`-versus-`deliver_sm` are, and the inline spec citations mark a rule without