Give the link's liveness one owner
Test / lint (pull_request) Successful in 24s
Test / test (18) (pull_request) Successful in 34s
Test / test (20) (pull_request) Successful in 32s
Test / test (22) (pull_request) Successful in 32s
Test / test (24) (pull_request) Successful in 38s
Test / test (26) (pull_request) Successful in 32s
Mirror / push (push) Successful in 7s

This commit was merged in pull request #48.
This commit is contained in:
2026-09-28 21:55:46 +02:00
parent 8721491cd5
commit 57f53d56f1
10 changed files with 306 additions and 182 deletions
+6 -9
View File
@@ -13,9 +13,7 @@ not for structure or style.
## Goals ## Goals
The goals, in priority order, live in The goals, in priority order, live in
[README.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/README.md#goals) — they say where this library is heading, which an outside [README.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/README.md#goals). The README states the audience alongside them.
reader judges it by. The README states the audience alongside them.
## Hard rules ## Hard rules
@@ -55,12 +53,12 @@ src/
held-messages.ts HeldMessages: capped, expiring messages the application has not answered, one MessageHold each held-messages.ts HeldMessages: capped, expiring messages the application has not answered, one MessageHold each
idle-waiters.ts IdleWaiters: waiting for a count to fall to zero, and what is left of a budget 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 incoming-requests.ts Every request the peer sends: messages, receipts, links, unknown commands
link-gate.ts LinkGate: where a request with no link to go out on waits for the next one link-life.ts LinkLife: whether the link lives, and where a request waits for the next one
link-timers.ts LinkTimers: the enquire_link heartbeat and the idle timeout link-timers.ts LinkTimers: the enquire_link heartbeat and the idle timeout
log.ts SmppLog, the logger contract, and silentLog — the default log.ts SmppLog, the logger contract, and silentLog — the default
message.ts Encoding detection, splitting, bit counting, SMPP date formatting message.ts Encoding detection, splitting, bit counting, SMPP date formatting
message-body.ts Where an inbound body is: short_message, or the message_payload TLV message-body.ts Where an inbound body is: short_message, or the message_payload TLV
outgoing-requests.ts OutgoingRequests: the gate, the window, the pending map and the retry outgoing-requests.ts OutgoingRequests: the window, the pending map and the retry
pdu.ts pduToObj / objToPdu / pduReturn — synchronous, result-returning pdu.ts pduToObj / objToPdu / pduReturn — synchronous, result-returning
pdu-framer.ts PduFramer: a byte stream cut into complete PDUs pdu-framer.ts PduFramer: a byte stream cut into complete PDUs
pdu-refusal.ts A PDU the codec would not read, and the answer SMPP names for it pdu-refusal.ts A PDU the codec would not read, and the answer SMPP names for it
@@ -180,7 +178,6 @@ decision under [The wire](docs/decisions.md#the-wire).
collaborator the type system already keeps in step: `recordingDeps()` in `messaging-mode.test.ts`, collaborator the type system already keeps in step: `recordingDeps()` in `messaging-mode.test.ts`,
`message-class.test.ts` and `unsendable.test.ts` is one `SendSmsDeps.send` that answers nothing, `message-class.test.ts` and `unsendable.test.ts` is one `SendSmsDeps.send` that answers nothing,
and a field added to that type fails to compile in every copy at once. and a field added to that type fails to compile in every copy at once.
- `message_id` values the library generates are UUID v7.
- A socket a test opens and never reads must be `resume()`d, and a `data` listener counts. An unread - A socket a test opens and never reads must be `resume()`d, and a `data` listener counts. An unread
socket never processes the peer's FIN, so `server.close()` hangs forever — that is a test bug, not socket never processes the peer's FIN, so `server.close()` hangs forever — that is a test bug, not
a library one. a library one.
@@ -314,13 +311,13 @@ the file.
- 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.
- The gate decides whether a link can carry a request, and a bind is what makes it one. - One owner decides whether a link can carry a request, and a bind is what makes it one.
### [Internals and tests](docs/decisions.md#internals-and-tests) ### [Internals and tests](docs/decisions.md#internals-and-tests)
- #30 and #46 merged under the comprehension floor, and Locality is the next work. - #30, #46 and #48 merged under the comprehension floor, and Locality is the next work.
- A listener that rejects is routed by Node's `captureRejections`, not by hand-dispatching. - A listener that rejects is routed by Node's `captureRejections`, not by hand-dispatching.
- The four-line abort dance is copied across `LinkGate`, `IdleWaiters`, `PendingRequests` and - The four-line abort dance is copied across `LinkLife`, `IdleWaiters`, `PendingRequests` and
`SendWindow` rather than extracted. `SendWindow` rather than extracted.
- `SmppLog` is a five-method contract this library declares, not a dependency. - `SmppLog` is a five-method contract this library declares, not a dependency.
- The TLS tests build their own self-signed certificate in DER - The TLS tests build their own self-signed certificate in DER
+1 -4
View File
@@ -746,10 +746,7 @@ Who depends on this library, and what they may rely on.
spooling, scheduling, retry policy and billing belong to whatever this is the edge of. State shared spooling, scheduling, retry policy and billing belong to whatever this is the edge of. State shared
between instances is for goal 9's store, which has not shipped: today every session keeps its own, between instances is for goal 9's store, which has not shipped: today every session keeps its own,
in memory. in memory.
- **Pre-1.0, so the minor is the breaking unit** and a patch never breaks. What a 0.4.0 consumer has - **Pre-1.0, so the minor is the breaking unit** and a patch never breaks.
to change is in [MIGRATION.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/MIGRATION.md);
what each later minor changes is in
[CHANGELOG.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/CHANGELOG.md).
Personas this README serves, in order: Personas this README serves, in order:
+18 -24
View File
@@ -508,10 +508,7 @@ rule and an index of the titles below.
- **`close` means the session is over, and a drop the loop will retry is `disconnected`.** - **`close` means the session is over, and a drop the loop will retry is `disconnected`.**
Maintainer's call, 2026-08-31: without the split, an application that opens a replacement client on Maintainer's call, 2026-08-31: without the split, an application that opens a replacement client on
`close` ends up holding two binds on one account. `teardown()` picks the event by whether the `close` ends up holding two binds on one account, which goal 4 forbids.
reconnect loop is still live, and `end()` stops that loop before tearing down, so every deliberate
shutdown emits `close`. A retry that opens a socket and then loses it resets `lifecycle` through
`attach()`, which is why a second drop emits again.
- **An answer belongs to the link the message arrived on; a receipt does not.** Maintainer's call, - **An answer belongs to the link the message arrived on; a receipt does not.** Maintainer's call,
2026-09-01. Rejected: answering on the new link, which succeeds and reports `{}` for a response 2026-09-01. Rejected: answering on the new link, which succeeds and reports `{}` for a response
@@ -541,9 +538,7 @@ rule and an index of the titles below.
gives up on the operator whose provisioning lands a minute later; the backoff is what bounds the gives up on the operator whose provisioning lands a minute later; the backoff is what bounds the
rate goal 4 cares about. The attempts before the first link report nothing, because the session rate goal 4 cares about. The attempts before the first link report nothing, because the session
running one has not reached the application: `disconnected` would have no listener and `close` running one has not reached the application: `disconnected` would have no listener and `close`
would be a lie. Its wait is the one retry timer that is not `unref()`'d, for the reason would be a lie.
`LinkGate`'s hold is not — it is awaited with no other handle, so a process whose only work is
`client()` would exit unbound.
- **`connectTimeout` defaults to 10 s, bounds the whole connect including the TLS handshake, and - **`connectTimeout` defaults to 10 s, bounds the whole connect including the TLS handshake, and
`false` is the one way to turn it off.** Maintainer's call, 2026-09-20, serving goal 5: a connect `false` is the one way to turn it off.** Maintainer's call, 2026-09-20, serving goal 5: a connect
@@ -676,7 +671,7 @@ rule and an index of the titles below.
the peer, whose every request is bounded by `responseTimeout` unless the caller set that to 0 as the peer, whose every request is bounded by `responseTimeout` unless the caller set that to 0 as
well, and unsafe for the application, which nothing bounds — `close()` is what you reach for when well, and unsafe for the application, which nothing bounds — `close()` is what you reach for when
the application is stuck, so it may not block on the application coming unstuck. That half falls the application is stuck, so it may not block on the application coming unstuck. That half falls
back to `responseTimeout`, the same answer the link gate's hold already takes — and to that back to `responseTimeout`, the same answer `LinkLife`'s hold already takes — and to that
option's default where it is 0 as well, since neither option is an answer about the application. option's default where it is 0 as well, since neither option is an answer about the application.
- **What the application holds unanswered is capped on constants, and a message past the cap is - **What the application holds unanswered is capped on constants, and a message past the cap is
@@ -738,10 +733,8 @@ rule and an index of the titles below.
optional so every construction site answers. `UnansweredError` stays unexported: `unanswered` is optional so every construction site answers. `UnansweredError` stays unexported: `unanswered` is
the one spelling on the public surface. The hold is bounded by `responseTimeout` rather than an the one spelling on the public surface. The hold is bounded by `responseTimeout` rather than an
option of its own — that is already the answer to how long one request may wait — and its clock option of its own — that is already the answer to how long one request may wait — and its clock
starts when the send is issued rather than when it first finds the gate shut, so one budget covers starts when the send is issued rather than when it first finds the link down, so one budget covers
every hold a single call makes. That timer is the one here that is not `unref()`'d: a held request every hold a single call makes.
is awaited with the socket already destroyed, so an unref'd one lets a process whose only remaining
work is that send exit without settling it.
- **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.**
Maintainer's call, 2026-09-06, from a review of PR #71: the hold above observes the signal and the Maintainer's call, 2026-09-06, from a review of PR #71: the hold above observes the signal and the
@@ -757,29 +750,30 @@ rule and an index of the titles below.
window is this end's own concurrency draining as the peer answers rather than a link going nowhere, window is this end's own concurrency draining as the peer answers rather than a link going nowhere,
and that bound would fail a message with more segments than `maxOutstanding` partway through and that bound would fail a message with more segments than `maxOutstanding` partway through
against a slow peer. The failure is a plain `Error` rather than `UnansweredError`, the same answer against a slow peer. The failure is a plain `Error` rather than `UnansweredError`, the same answer
an abort at the gate already gives. The drain half needs nothing: `close({ signal })` already hands the signal to an abort while held for a link already gives. The drain half needs nothing: `close({ signal })` already hands the signal to
`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.** - **One owner decides whether a link can carry a request, and a bind is what makes it one.**
Maintainer's call, 2026-09-01: `attach()` marks the session attached the moment a socket is Maintainer's call, 2026-09-01, extended 2026-09-28; goal 1, since a send on a link not yet bound
handed over, one round trip before the bind is answered, so gating on that let a send arriving in comes back `ESME_RINVBNDSTS`. `LinkLife` is told what happened and never reads back into the
that window go out unbound and come back `ESME_RINVBNDSTS`. The gate is told what happened and session; every other collaborator reads it and keeps no copy. Rejected: gating on the socket being
never reads back into the session: a collaborator that has to ask does not own its decision, which attached, which admits a send one round trip before the bind is answered, and collaborators that
is how the first cut ended up answering the same question two different ways at admit and at ask the session, which answered the same question two ways at admit and at release.
release. The retry in `requestPastDrain()` asks `gate.awaitsNextLink()` rather than `canCarry()`, `ReconnectLoop.halted` is the one other flag, because `client()` also runs a loop with no session
which also reads the socket: a loop condition the gate does not gate on spins against a gate that behind it for `fromStart`; a session's loop is stopped by `Session.stop()` alone.
admits it straight back.
## Internals and tests ## Internals and tests
- **#30 and #46 merged under the comprehension floor, and Locality is the next work.** Maintainer's call, - **#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 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 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 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 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 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. 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.
- **A listener that rejects is routed by Node's `captureRejections`, not by hand-dispatching.** Both - **A listener that rejects is routed by Node's `captureRejections`, not by hand-dispatching.** Both
emitters construct with `captureRejections: true` and implement emitters construct with `captureRejections: true` and implement
@@ -790,7 +784,7 @@ rule and an index of the titles below.
handlers normalise through `errorFrom()` rather than inline — a route out of the handler would land handlers normalise through `errorFrom()` rather than inline — a route out of the handler would land
on a bare `process.nextTick` with nothing to catch it. on a bare `process.nextTick` with nothing to catch it.
- **The four-line abort dance is copied across `LinkGate`, `IdleWaiters`, `PendingRequests` and - **The four-line abort dance is copied across `LinkLife`, `IdleWaiters`, `PendingRequests` and
`SendWindow` rather than extracted.** Architecture review, 2026-09-06: pre-check `aborted`, attach `SendWindow` rather than extracted.** Architecture review, 2026-09-06: pre-check `aborted`, attach
`{ once: true }`, detach on settle, leave the registry. What differs at each site is the registry `{ once: true }`, detach on settle, leave the registry. What differs at each site is the registry
and what settling means — a FIFO handing over a slot, a set released together, a map keyed by and what settling means — a FIFO handing over a slot, a set released together, a map keyed by
+8 -6
View File
@@ -1,6 +1,7 @@
import type { Concat } from './concat.ts'; import type { Concat } from './concat.ts';
import type { DlrMerger } from './dlr-merger.ts'; import type { DlrMerger } from './dlr-merger.ts';
import type { ErrorName } from './defs/errors.ts'; import type { ErrorName } from './defs/errors.ts';
import type { LinkLife } from './link-life.ts';
import type { LostGroup, Refusal } from './reassembly.ts'; import type { LostGroup, Refusal } from './reassembly.ts';
import type { OnRequest } from './session-options.ts'; import type { OnRequest } from './session-options.ts';
import type { PduObject, PduObjectInput } from './pdu.ts'; import type { PduObject, PduObjectInput } from './pdu.ts';
@@ -45,6 +46,7 @@ const lostReasons: Record<LostGroup['reason'], string> = {
export type IncomingRequestsOptions = { export type IncomingRequestsOptions = {
dlrMerger: DlrMerger; dlrMerger: DlrMerger;
link: LinkLife;
log: SmppLog; log: SmppLog;
maxOctets?: number | undefined; maxOctets?: number | undefined;
maxReassembly?: number | undefined; maxReassembly?: number | undefined;
@@ -61,6 +63,7 @@ export type IncomingRequestsOptions = {
export class IncomingRequests { export class IncomingRequests {
private readonly dlrMerger: DlrMerger; private readonly dlrMerger: DlrMerger;
private readonly held: HeldMessages; private readonly held: HeldMessages;
private readonly link: LinkLife;
private readonly log: SmppLog; private readonly log: SmppLog;
private readonly onRequest: OnRequest | undefined; private readonly onRequest: OnRequest | undefined;
private readonly reassembler: Reassembler; private readonly reassembler: Reassembler;
@@ -68,7 +71,6 @@ export class IncomingRequests {
private readonly session: Session; private readonly session: Session;
private readonly smsIdFormat: SmsIdFormat; private readonly smsIdFormat: SmsIdFormat;
private readonly systemId: string; private readonly systemId: string;
private linkGeneration = 0;
private refusing = false; private refusing = false;
constructor(options: IncomingRequestsOptions) { constructor(options: IncomingRequestsOptions) {
@@ -79,6 +81,7 @@ export class IncomingRequests {
maxOctets: defaults.maxHeldOctets, maxOctets: defaults.maxHeldOctets,
timeout: defaults.heldMessageTimeout, timeout: defaults.heldMessageTimeout,
}); });
this.link = options.link;
this.log = options.log; this.log = options.log;
this.onRequest = options.onRequest; this.onRequest = options.onRequest;
this.reassembler = new Reassembler({ this.reassembler = new Reassembler({
@@ -95,14 +98,14 @@ export class IncomingRequests {
} }
async handle(pduObj: PduObject): Promise<void> { async handle(pduObj: PduObject): Promise<void> {
const generation = this.linkGeneration; const generation = this.link.generation();
const { onRequest } = this; const { onRequest } = this;
// Called unbound, so the application's hook never sees this class as its `this`. // Called unbound, so the application's hook never sees this class as its `this`.
if (onRequest && await onRequest(this.session, pduObj)) return; if (onRequest && await onRequest(this.session, pduObj)) return;
// The link it arrived on went while the hook ran, so nothing we answer now correlates. // The link it arrived on went while the hook ran, so nothing we answer now correlates.
if (this.linkGeneration !== generation) { if (this.link.generation() !== generation) {
this.log.info('session - dropping a request whose link went', { cmdName: pduObj.cmdName }); this.log.info('session - dropping a request whose link went', { cmdName: pduObj.cmdName });
return; return;
@@ -148,7 +151,6 @@ export class IncomingRequests {
/** Drops the segments of every message that never became whole, and of every one still held. */ /** Drops the segments of every message that never became whole, and of every one still held. */
clear(): void { clear(): void {
this.linkGeneration++;
this.refusing = false; this.refusing = false;
this.held.clear(); this.held.clear();
this.reassembler.clear(); this.reassembler.clear();
@@ -288,7 +290,7 @@ export class IncomingRequests {
if (!first) return; if (!first) return;
const generation = this.linkGeneration; const generation = this.link.generation();
this.held.offer(pduObjs, this.session.listenerCount('sms'), hold => createSms({ this.held.offer(pduObjs, this.session.listenerCount('sms'), hold => createSms({
answeredAs, answeredAs,
@@ -298,7 +300,7 @@ export class IncomingRequests {
session: this.session, session: this.session,
to: paramText(first.params.destination_addr), to: paramText(first.params.destination_addr),
}, { }, {
lostLink: () => this.linkGeneration !== generation, lostLink: () => this.link.generation() !== generation,
onAnswered: () => { hold.answered(); }, onAnswered: () => { hold.answered(); },
send: input => (hold.isHeld() ? this.sendPastDrain(input) : this.session.send(input)), send: input => (hold.isHeld() ? this.sendPastDrain(input) : this.session.send(input)),
}), sms => this.session.emit('sms', sms)); }), sms => this.session.emit('sms', sms));
+80 -25
View File
@@ -1,13 +1,18 @@
import type { SmppLog } from './log.ts'; import type { SmppLog } from './log.ts';
import type { VoidResult } from './result.ts'; import type { VoidResult } from './result.ts';
export type LinkGateOptions = { export type LinkLifeOptions = {
log: SmppLog; log: SmppLog;
now?: (() => number) | undefined; now?: (() => number) | undefined;
/** Whether a dropped link is followed by another one until stop(). */
reconnects: boolean;
/** How long a request may wait for a link. 0 waits for as long as one may still arrive. */ /** How long a request may wait for a link. 0 waits for as long as one may still arrive. */
timeout: number; timeout: number;
}; };
/** `binding`: a socket is attached and its bind is not answered yet, so it carries nothing but that bind. */
type Phase = 'binding' | 'down' | 'ended' | 'up';
type Waiter = (result: VoidResult) => void; type Waiter = (result: VoidResult) => void;
function aborted(): Error { function aborted(): Error {
@@ -22,66 +27,116 @@ function over(): Error {
return new Error('Session is closed'); return new Error('Session is closed');
} }
/** Where a request with no link to go out on waits for the next one. */ /** Whether the session's link lives, and where a request with no link to go out on waits for the next one. */
export class LinkGate { export class LinkLife {
private readonly log: SmppLog; private readonly log: SmppLog;
private readonly now: () => number; private readonly now: () => number;
private readonly reconnects: boolean;
private readonly timeout: number; private readonly timeout: number;
private readonly waiting = new Set<Waiter>(); private readonly waiting = new Set<Waiter>();
private returning = false; private drops = 0;
private up = true; private phase: Phase = 'up';
private stopped = false;
constructor(options: LinkGateOptions) { constructor(options: LinkLifeOptions) {
this.log = options.log; this.log = options.log;
this.now = options.now ?? Date.now; this.now = options.now ?? Date.now;
this.reconnects = options.reconnects;
this.timeout = options.timeout; this.timeout = options.timeout;
} }
/** Whether a request can go out right now. A link that is attached but not yet bound cannot. */ /** A socket is on the link, bound or not. */
isAttached(): boolean {
return this.phase === 'binding' || this.phase === 'up';
}
/** Whether a request can go out right now. */
isUp(): boolean { isUp(): boolean {
return this.up; return this.phase === 'up';
} }
/** Shut, with another link on its way to reopen it. */ private isOver(): boolean {
return this.phase === 'ended';
}
/** The session is shutting down: nothing new is taken, and no link follows this one. */
isStopped(): boolean {
return this.stopped;
}
/** Whether a link that drops now is followed by another. */
retrying(): boolean {
return this.reconnects && !this.stopped;
}
/** Not up and not over, with a link to come. */
awaitsNextLink(): boolean { awaitsNextLink(): boolean {
return !this.up && this.returning; return !this.isUp() && !this.isOver() && this.retrying();
} }
/** Why the gate will never admit a request, or undefined while one may still get through. */ /** Changes with every drop, so what was read off one link can tell that link is gone. */
generation(): number {
return this.drops;
}
/** Why no request will ever be admitted, or undefined while one may still get through. */
refusal(): Error | undefined { refusal(): Error | undefined {
return this.up || this.returning ? undefined : over(); return this.isUp() || this.awaitsNextLink() ? undefined : over();
} }
/** One budget for a request, however many links it waits through. 0 never gives up. */ /** One budget for a request, however many links it waits through. */
hold(signal: AbortSignal | undefined): () => Promise<VoidResult> { hold(signal: AbortSignal | undefined): () => Promise<VoidResult> {
const deadline = this.timeout > 0 ? this.now() + this.timeout : 0; const deadline = this.timeout > 0 ? this.now() + this.timeout : 0;
return () => this.wait(deadline, signal); return () => this.wait(deadline, signal);
} }
/** A link is up and bound: everything held goes out on it. */ /** A socket from the reconnect loop, not yet bound. An ended session stays ended. */
attach(): void {
if (this.isOver()) return;
this.phase = 'binding';
}
/** The link is bound: everything held goes out on it. */
open(): void { open(): void {
this.up = true; this.phase = 'up';
this.returning = false;
if (this.waiting.size > 0) { if (this.waiting.size > 0) {
this.log.verbose('linkGate - sending what was held for a link', { held: this.waiting.size }); this.log.verbose('linkLife - sending what was held for a link', { held: this.waiting.size });
} }
this.release({}); this.release({});
} }
/** The link is gone. `returning` says whether another one is on its way. */ /** The attached link is gone: the event that says so, or undefined when there was none to lose. */
shut(returning: boolean): void { drop(): 'close' | 'disconnected' | undefined {
this.up = false; if (!this.isAttached()) return undefined;
this.returning = returning;
if (!returning) this.release({ err: over() }); this.phase = 'down';
this.drops++;
return this.retrying() ? 'disconnected' : 'close';
}
stop(): void {
this.stopped = true;
}
/** The session is over: nothing held will ever go out. False means it already was. */
end(): boolean {
if (this.isOver()) return false;
this.phase = 'ended';
this.stopped = true;
this.release({ err: over() });
return true;
} }
/** Resolves once a link can carry the request, or with the reason none ever will. */ /** Resolves once a link can carry the request, or with the reason none ever will. */
private wait(deadline: number, signal: AbortSignal | undefined): Promise<VoidResult> { private wait(deadline: number, signal: AbortSignal | undefined): Promise<VoidResult> {
if (this.up) return Promise.resolve({}); if (this.isUp()) return Promise.resolve({});
const refused = this.refusal(); const refused = this.refusal();
@@ -97,7 +152,7 @@ export class LinkGate {
} }
private waitForLink(left: number, signal: AbortSignal | undefined): Promise<VoidResult> { private waitForLink(left: number, signal: AbortSignal | undefined): Promise<VoidResult> {
this.log.verbose('linkGate - holding a request until a link is back', { timeout: left }); this.log.verbose('linkLife - holding a request until a link is back', { timeout: left });
return new Promise<VoidResult>(resolve => { return new Promise<VoidResult>(resolve => {
let timer: NodeJS.Timeout | undefined = undefined; let timer: NodeJS.Timeout | undefined = undefined;
@@ -109,7 +164,7 @@ export class LinkGate {
resolve(result); resolve(result);
}; };
const giveUp = (): void => { const giveUp = (): void => {
this.log.warn('linkGate - no link came back in time', { timeout: left }); this.log.warn('linkLife - no link came back in time', { timeout: left });
settle({ err: expired() }); settle({ err: expired() });
}; };
+17 -35
View File
@@ -1,9 +1,9 @@
import type { LinkLife } from './link-life.ts';
import type { PduObject, PduObjectInput } from './pdu.ts'; import type { PduObject, PduObjectInput } from './pdu.ts';
import type { PduTransport } from './pdu-transport.ts'; import type { PduTransport } from './pdu-transport.ts';
import type { Result, VoidResult } from './result.ts'; import type { Result, VoidResult } from './result.ts';
import type { SendOptions } from './session-options.ts'; import type { SendOptions } from './session-options.ts';
import type { SmppLog } from './log.ts'; import type { SmppLog } from './log.ts';
import { LinkGate } from './link-gate.ts';
import { PendingRequests } from './pending-requests.ts'; import { PendingRequests } from './pending-requests.ts';
import { SendWindow } from './send-window.ts'; import { SendWindow } from './send-window.ts';
import { UnansweredError } from './unanswered-error.ts'; import { UnansweredError } from './unanswered-error.ts';
@@ -11,6 +11,7 @@ import { bindCommands } from './session-options.ts';
import { objToPdu } from './pdu.ts'; import { objToPdu } from './pdu.ts';
export type OutgoingRequestsOptions = { export type OutgoingRequestsOptions = {
link: LinkLife;
log: SmppLog; log: SmppLog;
maxOutstanding: number; maxOutstanding: number;
responseTimeout: number; responseTimeout: number;
@@ -33,17 +34,15 @@ function misuse(input: PduObjectInput): Error | undefined {
/** Everything this end asks of the peer: which link carries it, how many at once, and the answer. */ /** Everything this end asks of the peer: which link carries it, how many at once, and the answer. */
export class OutgoingRequests { export class OutgoingRequests {
private readonly gate: LinkGate; private readonly link: LinkLife;
private readonly log: SmppLog; private readonly log: SmppLog;
private readonly pending: PendingRequests; private readonly pending: PendingRequests;
private readonly responseTimeout: number; private readonly responseTimeout: number;
private readonly transport: PduTransport; private readonly transport: PduTransport;
private readonly window: SendWindow; private readonly window: SendWindow;
private draining = false;
constructor(options: OutgoingRequestsOptions) { constructor(options: OutgoingRequestsOptions) {
this.gate = new LinkGate({ log: options.log, timeout: options.responseTimeout }); this.link = options.link;
this.log = options.log; this.log = options.log;
this.pending = new PendingRequests(options.log); this.pending = new PendingRequests(options.log);
this.responseTimeout = options.responseTimeout; this.responseTimeout = options.responseTimeout;
@@ -52,22 +51,11 @@ export class OutgoingRequests {
} }
canCarry(): boolean { canCarry(): boolean {
return this.gate.isUp() && !this.transport.sock.destroyed; return this.link.isUp() && !this.transport.sock.destroyed;
} }
/** The link went before the drain finished, so an empty window says nothing about the peer. */ /** The link is gone, and every answer still owed on it with it. */
droppedWhileDraining(): boolean { linkLost(): void {
return this.draining && !this.canCarry();
}
/** A link is up and bound, so everything held for one goes out on it. */
linkUp(): void {
this.gate.open();
}
/** The link is gone; `returning` says whether another one is on its way. */
linkLost(returning: boolean): void {
this.gate.shut(returning);
this.pending.settleAll(new Error('Session closed before a response arrived')); this.pending.settleAll(new Error('Session closed before a response arrived'));
} }
@@ -81,7 +69,6 @@ export class OutgoingRequests {
this.pending.settle(seqNr, { err }); this.pending.settle(seqNr, { err });
} }
/** Sends a request and resolves with the peer's response. */
request(input: PduObjectInput, options: SendOptions): Promise<Result<{ pduObj: PduObject }>> { request(input: PduObjectInput, options: SendOptions): Promise<Result<{ pduObj: PduObject }>> {
// Ahead of the drain, so a misuse is named as one rather than blamed on the shutdown. // Ahead of the drain, so a misuse is named as one rather than blamed on the shutdown.
const wrong = misuse(input); const wrong = misuse(input);
@@ -89,7 +76,7 @@ export class OutgoingRequests {
if (wrong) return Promise.resolve({ err: wrong }); if (wrong) return Promise.resolve({ err: wrong });
// With no link, the request is refused as closed further on. // With no link, the request is refused as closed further on.
if (this.draining && this.canCarry()) { if (this.link.isStopped() && this.canCarry()) {
return Promise.resolve({ err: new Error('Session is shutting down') }); return Promise.resolve({ err: new Error('Session is shutting down') });
} }
@@ -105,14 +92,14 @@ export class OutgoingRequests {
if (refused) return { err: refused }; if (refused) return { err: refused };
// A bind is what makes a link usable, so it cannot wait for one: it takes the gate's answer now. // A bind is what makes a link usable, so it cannot wait for one: it takes the link's answer now.
if (bindCommands.includes(input.cmdName)) { if (bindCommands.includes(input.cmdName)) {
const shut = this.gate.refusal(); const shut = this.link.refusal();
return shut ? { err: shut } : this.requestPastDrainGateAndWindow(input, options); return shut ? { err: shut } : this.requestOnCurrentLink(input, options);
} }
const waitForLink = this.gate.hold(options.signal); const waitForLink = this.link.hold(options.signal);
for (;;) { for (;;) {
const held = await waitForLink(); const held = await waitForLink();
@@ -130,18 +117,13 @@ export class OutgoingRequests {
} }
/** Straight onto the current link, for what has to go out either way. */ /** Straight onto the current link, for what has to go out either way. */
async requestPastDrainGateAndWindow( async requestOnCurrentLink(
input: PduObjectInput, input: PduObjectInput,
options: SendOptions = {}, options: SendOptions = {},
): Promise<Result<{ pduObj: PduObject }>> { ): Promise<Result<{ pduObj: PduObject }>> {
return (await this.attempt(input, options)).result; return (await this.attempt(input, options)).result;
} }
/** Refuses every request from here on, on a link that is already down as much as a live one. */
stopAccepting(): void {
this.draining = true;
}
/** Waits out the requests already on the wire, and says how many never finished. */ /** Waits out the requests already on the wire, and says how many never finished. */
async drain(timeout: number, signal: AbortSignal | undefined): Promise<VoidResult> { async drain(timeout: number, signal: AbortSignal | undefined): Promise<VoidResult> {
const unfinished = await this.window.idle(timeout, signal); const unfinished = await this.window.idle(timeout, signal);
@@ -153,15 +135,15 @@ export class OutgoingRequests {
return { err: new Error(`Shut down with ${String(unfinished)} request(s) unfinished`) }; return { err: new Error(`Shut down with ${String(unfinished)} request(s) unfinished`) };
} }
/** Nothing reached the socket, so the next link carries it instead of the caller resending. */ /** Nothing reached the socket, so the next link carries it. */
private retriesOnNextLink(attempt: Attempt): boolean { private retriesOnNextLink(attempt: Attempt): boolean {
// Until the gate is shut it admits the retry straight back onto the dead socket, and the loop spins. // Until the link is dropped it admits the retry straight back onto the dead socket, and the loop spins.
return attempt.retryOnNextLink && this.gate.awaitsNextLink(); return attempt.retryOnNextLink && this.link.awaitsNextLink();
} }
/** Why a request cannot go out at all, as opposed to not yet. */ /** Why a request cannot go out at all, as opposed to not yet. */
private refuse(input: PduObjectInput, options: SendOptions): Error | undefined { private refuse(input: PduObjectInput, options: SendOptions): Error | undefined {
// Before the gate and the window, or an aborted call waits for what it will never use. // Before the link and the window, or an aborted call waits for what it will never use.
return misuse(input) ?? (options.signal?.aborted === true ? abortedBeforeSend() : undefined); return misuse(input) ?? (options.signal?.aborted === true ? abortedBeforeSend() : undefined);
} }
+1 -1
View File
@@ -40,7 +40,7 @@ export class ReconnectLoop {
} }
/** Read through a method: stop() can land while an attempt is awaiting. */ /** Read through a method: stop() can land while an attempt is awaiting. */
isStopped(): boolean { private isStopped(): boolean {
return this.halted; return this.halted;
} }
+40 -41
View File
@@ -11,6 +11,7 @@ import type { Socket } from 'node:net';
import { DlrMerger } from './dlr-merger.ts'; import { DlrMerger } from './dlr-merger.ts';
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { IncomingRequests } from './incoming-requests.ts'; import { IncomingRequests } from './incoming-requests.ts';
import { LinkLife } from './link-life.ts';
import { LinkTimers } from './link-timers.ts'; import { LinkTimers } from './link-timers.ts';
import { OutgoingRequests } from './outgoing-requests.ts'; import { OutgoingRequests } from './outgoing-requests.ts';
import { PduTransport } from './pdu-transport.ts'; import { PduTransport } from './pdu-transport.ts';
@@ -61,15 +62,13 @@ export class Session extends EventEmitter<SessionEvents> {
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;
private readonly link: LinkLife;
private readonly options: SessionOptions; private readonly options: SessionOptions;
private readonly outgoing: OutgoingRequests; private readonly outgoing: OutgoingRequests;
private readonly reconnectLoop: ReconnectLoop | undefined; private readonly reconnectLoop: ReconnectLoop | undefined;
private readonly timers: LinkTimers; private readonly timers: LinkTimers;
private readonly transport: PduTransport; private readonly transport: PduTransport;
/** `ended` is final: end() stops the reconnect loop before any attach() can run. */
private lifecycle: 'attached' | 'ended' | 'torn-down' = 'attached';
/** A listener that throws is the application's bug; it must not become ours. Hard rule 1. */ /** A listener that throws is the application's bug; it must not become ours. Hard rule 1. */
override emit<K extends keyof SessionEvents>( override emit<K extends keyof SessionEvents>(
event: K, event: K,
@@ -109,12 +108,12 @@ export class Session extends EventEmitter<SessionEvents> {
this.log = guardedLog(options.log); this.log = guardedLog(options.log);
this.options = options; this.options = options;
this.dlrMerger = new DlrMerger({ this.dlrMerger = new DlrMerger({ log: this.log, max: defaults.maxDlrMerges, timeout: defaults.dlrMergeTimeout });
log: this.log,
max: defaults.maxDlrMerges,
timeout: defaults.dlrMergeTimeout,
});
this.reconnectLoop = this.loopFor(options.reconnect); this.reconnectLoop = this.loopFor(options.reconnect);
const responseTimeout = options.responseTimeout ?? defaults.responseTimeout;
this.link = new LinkLife({ log: this.log, reconnects: this.reconnectLoop !== undefined, timeout: responseTimeout });
this.timers = new LinkTimers({ this.timers = new LinkTimers({
enquireLinkInterval: options.enquireLinkInterval, enquireLinkInterval: options.enquireLinkInterval,
idleTimeout: options.idleTimeout, idleTimeout: options.idleTimeout,
@@ -125,13 +124,15 @@ export class Session extends EventEmitter<SessionEvents> {
}); });
this.transport = this.transportFor(options.sock); this.transport = this.transportFor(options.sock);
this.outgoing = new OutgoingRequests({ this.outgoing = new OutgoingRequests({
link: this.link,
log: this.log, log: this.log,
maxOutstanding: options.maxOutstanding ?? defaults.maxOutstanding, maxOutstanding: options.maxOutstanding ?? defaults.maxOutstanding,
responseTimeout: options.responseTimeout ?? defaults.responseTimeout, responseTimeout,
transport: this.transport, transport: this.transport,
}); });
this.incoming = new IncomingRequests({ this.incoming = new IncomingRequests({
dlrMerger: this.dlrMerger, dlrMerger: this.dlrMerger,
link: this.link,
log: this.log, log: this.log,
maxOctets: options.maxOctets, maxOctets: options.maxOctets,
maxReassembly: options.maxReassembly, maxReassembly: options.maxReassembly,
@@ -199,7 +200,7 @@ export class Session extends EventEmitter<SessionEvents> {
const sent = built.err ? { err: built.err } : this.transport.write(built.buffer); const sent = built.err ? { err: built.err } : this.transport.write(built.buffer);
// A peer that unbinds and drops the link takes our response with it; that is not a failure. // A peer that unbinds and drops the link takes our response with it; that is not a failure.
if (sent.err && this.lifecycle === 'attached') { if (sent.err && this.link.isAttached()) {
this.log.warn('session - could not answer a request', { this.log.warn('session - could not answer a request', {
cmdName, cmdName,
message: sent.err.message, message: sent.err.message,
@@ -234,11 +235,11 @@ 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.link.isAttached();
const sent = wasOpen const sent = wasOpen
? await this.outgoing.requestPastDrainGateAndWindow({ cmdName: 'unbind' }) ? await this.outgoing.requestOnCurrentLink({ cmdName: 'unbind' })
: { err: new Error('Session is closed') }; : { err: new Error('Session is closed') };
const closedOnUnbind = wasOpen && this.lifecycle !== 'attached'; const closedOnUnbind = wasOpen && !this.link.isAttached();
this.end(); this.end();
@@ -246,8 +247,8 @@ export class Session extends EventEmitter<SessionEvents> {
} }
/** /**
* Closes for good: refuses new sends, waits out the requests already on the wire up to * Closes for good: refuses new sends, waits up to `shutdownTimeout` for the requests already sent
* `shutdownTimeout`, then tears down whatever is left. A session closed this way never reconnects. * and the messages not yet answered, then tears down whatever is left. A session closed this way never reconnects.
*/ */
async close(options: CloseOptions = {}): Promise<VoidResult> { async close(options: CloseOptions = {}): Promise<VoidResult> {
const drained = await this.drain(options.signal); const drained = await this.drain(options.signal);
@@ -300,14 +301,14 @@ export class Session extends EventEmitter<SessionEvents> {
} }
// close() can land while the rebind is in flight. // close() can land while the rebind is in flight.
if (this.reconnectLoop?.isStopped() === true) { if (!this.link.retrying()) {
this.teardown(); this.teardown();
return { err: new Error('Session closed while it was coming back up') }; return { err: new Error('Session closed while it was coming back up') };
} }
this.resetTimers(); this.resetTimers();
this.outgoing.linkUp(); this.link.open();
this.log.info('session - reconnected'); this.log.info('session - reconnected');
this.emit('reconnected'); this.emit('reconnected');
@@ -316,15 +317,14 @@ export class Session extends EventEmitter<SessionEvents> {
private attach(sock: Socket): void { private attach(sock: Socket): void {
this.transport.attach(sock); this.transport.attach(sock);
this.lifecycle = 'attached'; this.link.attach();
} }
/** Stops new sends and waits out the messages we hold and the requests already issued. */ /** Stops new sends and waits out the messages we hold and the requests already issued. */
private async drain(signal: AbortSignal | undefined): Promise<VoidResult> { private async drain(signal: AbortSignal | undefined): Promise<VoidResult> {
this.reconnectLoop?.stop(); this.stop();
this.outgoing.stopAccepting();
// No link, so nothing is on the wire to wait out. // No bound link, so nothing is on the wire to wait out.
if (!this.outgoing.canCarry()) return {}; if (!this.outgoing.canCarry()) return {};
const timeout = this.options.shutdownTimeout ?? defaults.shutdownTimeout; const timeout = this.options.shutdownTimeout ?? defaults.shutdownTimeout;
@@ -333,7 +333,8 @@ export class Session extends EventEmitter<SessionEvents> {
const messages = await this.incoming.drain(this.answering(timeout), signal); const messages = await this.incoming.drain(this.answering(timeout), signal);
const requests = await this.outgoing.drain(leftOf(deadline), signal); const requests = await this.outgoing.drain(leftOf(deadline), signal);
if (this.outgoing.droppedWhileDraining()) { // The link went before the drain finished, so an empty window says nothing about the peer.
if (!this.outgoing.canCarry()) {
return { err: new Error('The session closed before the drain finished') }; return { err: new Error('The session closed before the drain finished') };
} }
@@ -355,42 +356,40 @@ export class Session extends EventEmitter<SessionEvents> {
/** The session is over now, drained or not. Nothing brings it back. */ /** The session is over now, drained or not. Nothing brings it back. */
private end(): void { private end(): void {
this.reconnectLoop?.stop(); this.stop();
this.teardown(); this.teardown();
this.dlrMerger.clear(); this.dlrMerger.clear();
this.emitClose(); this.emitClose();
} }
private emitClose(): void { /** No new sends, and no link after this one. */
if (this.lifecycle === 'ended') return; private stop(): void {
this.link.stop();
this.reconnectLoop?.stop();
}
this.lifecycle = 'ended'; private emitClose(): void {
this.outgoing.linkLost(false); if (!this.link.end()) return;
this.outgoing.linkLost();
this.emit('close'); this.emit('close');
} }
private teardown(): void { private teardown(): void {
if (this.lifecycle !== 'attached') return; const lost = this.link.drop();
this.lifecycle = 'torn-down'; if (!lost) return;
// Read once: clear() reports lost segments, and a listener could stop the loop between reads. this.outgoing.linkLost();
const retrying = this.retrying();
this.outgoing.linkLost(retrying);
this.timers.clear(); this.timers.clear();
this.incoming.clear(); this.incoming.clear();
this.sock.destroy(); this.sock.destroy();
if (retrying) this.emit('disconnected'); // `lost` is read before clear(): a listener it reaches may close() the session, and the drop still reports as disconnected.
if (lost === 'disconnected') this.emit('disconnected');
else this.emitClose(); else this.emitClose();
} }
// Copied into the gate at teardown, so stopping the loop anywhere but drain() and end() has to shut the gate too.
private retrying(): boolean {
return this.reconnectLoop !== undefined && !this.reconnectLoop.isStopped();
}
private onData(chunk: Buffer): void { private onData(chunk: Buffer): void {
this.emit('data', chunk); this.emit('data', chunk);
this.resetTimers(); this.resetTimers();
@@ -436,13 +435,13 @@ export class Session extends EventEmitter<SessionEvents> {
} }
private resetTimers(): void { private resetTimers(): void {
if (this.lifecycle !== 'attached') return; if (!this.link.isAttached()) return;
this.timers.reset(); this.timers.reset();
} }
private onClose(): void { private onClose(): void {
if (this.retrying()) { if (this.link.retrying()) {
this.teardown(); this.teardown();
this.reconnectLoop?.schedule(); this.reconnectLoop?.schedule();
+93 -23
View File
@@ -19,7 +19,7 @@ import { HeldMessages } from '../src/held-messages.ts';
import { IncomingRequests, refusedSegmentStatus } from '../src/incoming-requests.ts'; import { IncomingRequests, refusedSegmentStatus } from '../src/incoming-requests.ts';
import { UnansweredError } from '../src/unanswered-error.ts'; import { UnansweredError } from '../src/unanswered-error.ts';
import { createSms } from '../src/sms.ts'; import { createSms } from '../src/sms.ts';
import { LinkGate } from '../src/link-gate.ts'; import { LinkLife } from '../src/link-life.ts';
import { SendWindow } from '../src/send-window.ts'; import { SendWindow } from '../src/send-window.ts';
import { Reassembler, decodeSegments } from '../src/reassembly.ts'; import { Reassembler, decodeSegments } from '../src/reassembly.ts';
import { Session } from '../src/session.ts'; import { Session } from '../src/session.ts';
@@ -104,6 +104,7 @@ function abortAfter(
function incomingOn(session: Session, options: Partial<IncomingRequestsOptions> = {}): IncomingRequests { function incomingOn(session: Session, options: Partial<IncomingRequestsOptions> = {}): IncomingRequests {
return new IncomingRequests({ return new IncomingRequests({
dlrMerger: new DlrMerger({ log: silentLog, max: 10, timeout: 10_000 }), dlrMerger: new DlrMerger({ log: silentLog, max: 10, timeout: 10_000 }),
link: new LinkLife({ log: silentLog, reconnects: false, timeout: 100 }),
log: silentLog, log: silentLog,
sendPastDrain: () => Promise.resolve({ err: new Error('never sent') }), sendPastDrain: () => Promise.resolve({ err: new Error('never sent') }),
session, session,
@@ -748,14 +749,15 @@ describe('reconnect', () => {
closeAfter(t, session); closeAfter(t, session);
const incoming = incomingOn(session, { onRequest: async () => { await delay(10); return false; } }); const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 100 });
const incoming = incomingOn(session, { link, onRequest: async () => { await delay(10); return false; } });
let messages = 0; let messages = 0;
session.on('sms', () => { messages++; }); session.on('sms', () => { messages++; });
const handled = incoming.handle(submitPdu(1)); const handled = incoming.handle(submitPdu(1));
incoming.clear(); link.drop();
await handled; await handled;
@@ -1401,13 +1403,13 @@ describe('sends across a reconnect', () => {
}); });
}); });
describe('LinkGate', () => { describe('LinkLife', () => {
test('refuses a hold whose deadline has already passed', async () => { test('refuses a hold whose deadline has already passed', async () => {
let now = 0; let now = 0;
const gate = new LinkGate({ log: silentLog, now: () => now, timeout: 100 }); const link = new LinkLife({ log: silentLog, now: () => now, reconnects: true, timeout: 100 });
const waitForLink = gate.hold(undefined); const waitForLink = link.hold(undefined);
gate.shut(true); link.drop();
now = 101; now = 101;
const held = await waitForLink(); const held = await waitForLink();
@@ -1416,42 +1418,77 @@ describe('LinkGate', () => {
}); });
test('holds on a timer that keeps the process alive', async () => { test('holds on a timer that keeps the process alive', async () => {
const gate = new LinkGate({ log: silentLog, timeout: 10_000 }); const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 10_000 });
const timers = (): number => process.getActiveResourcesInfo().filter(name => name === 'Timeout').length; const timers = (): number => process.getActiveResourcesInfo().filter(name => name === 'Timeout').length;
gate.shut(true); link.drop();
const before = timers(); const before = timers();
const held = gate.hold(undefined)(); const held = link.hold(undefined)();
assert.equal(timers(), before + 1, 'an unref\'d timer is not counted here, which is the point'); assert.equal(timers(), before + 1, 'an unref\'d timer is not counted here, which is the point');
gate.open(); link.open();
assert.deepEqual(await held, {}); assert.deepEqual(await held, {});
}); });
// addEventListener never fires for a signal that already aborted, so it would wait out the timeout. // addEventListener never fires for a signal that already aborted, so it would wait out the timeout.
test('gives up at once on a signal that was already aborted', async () => { test('gives up at once on a signal that was already aborted', async () => {
const gate = new LinkGate({ log: silentLog, timeout: 100 }); const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 100 });
gate.shut(true); link.drop();
const held = await gate.hold(AbortSignal.abort())(); const held = await link.hold(AbortSignal.abort())();
assert.match(held.err?.message ?? '', /Aborted while waiting for a link/); assert.match(held.err?.message ?? '', /Aborted while waiting for a link/);
}); });
test('awaits the next link only while shut with one on its way', () => { test('awaits the next link only while down with one on its way', () => {
const gate = new LinkGate({ log: silentLog, timeout: 100 }); const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 100 });
assert.equal(gate.awaitsNextLink(), false, 'up'); assert.equal(link.awaitsNextLink(), false, 'up');
gate.shut(true); link.drop();
assert.equal(gate.awaitsNextLink(), true, 'shut, returning'); assert.equal(link.awaitsNextLink(), true, 'down, returning');
gate.open(); link.attach();
assert.equal(gate.awaitsNextLink(), false, 'reopened'); assert.equal(link.awaitsNextLink(), true, 'attached, not yet bound');
gate.shut(false); link.open();
assert.equal(gate.awaitsNextLink(), false, 'shut for good'); assert.equal(link.awaitsNextLink(), false, 'reopened');
link.drop();
link.stop();
assert.equal(link.awaitsNextLink(), false, 'down, stopped');
assert.match(link.refusal()?.message ?? '', /closed/, 'stopped while down');
link.end();
assert.equal(link.awaitsNextLink(), false, 'ended');
});
test('drops an attached link once, counts each drop, and names the event it warrants', () => {
const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 100 });
const generation = link.generation();
assert.equal(link.drop(), 'disconnected');
assert.equal(link.drop(), undefined, 'already down');
assert.equal(link.generation(), generation + 1);
link.attach();
link.stop();
assert.equal(link.drop(), 'close', 'a new link drops again, with none to follow it');
assert.equal(link.generation(), generation + 2);
assert.equal(new LinkLife({ log: silentLog, reconnects: false, timeout: 100 }).drop(), 'close');
});
test('releases a held request with the reason once the link ends', async () => {
const link = new LinkLife({ log: silentLog, reconnects: true, timeout: 0 });
link.drop();
const held = link.hold(undefined)();
link.end();
assert.match((await held).err?.message ?? '', /Session is closed/);
link.attach();
assert.equal(link.isAttached(), false, 'ended is final');
assert.equal(link.end(), false);
}); });
}); });
@@ -2424,6 +2461,39 @@ describe('a peer that sends the next segment only once the last one is answered'
assert.deepEqual(await peerOf(smpp).close(), {}, 'a group nothing completed is not held'); assert.deepEqual(await peerOf(smpp).close(), {}, 'a group nothing completed is not held');
}); });
test('reports a drop once as disconnected when a listener closes the session over the segments it lost', async t => {
const smpp = await startServer(t);
const { session } = await connect(t, smpp, { reconnect: { maxDelay: 100, minDelay: 20 } });
assert.ok(session);
const events: string[] = [];
const closed = once<true>(resolve => { session.on('close', () => { resolve(true); }); });
session.on('close', () => { events.push('close'); });
session.on('disconnected', () => { events.push('disconnected'); });
session.on('sessionError', () => { void session.close(); });
const [first] = segmentsOf(0x2D);
assert.ok(first);
const delivered = await peerOf(smpp).send({
cmdName: 'deliver_sm',
params: submitSmParams(
{ from: '46701113311', message: text, to: '46709771337' },
first,
{ encoding: 'ASCII', multipart: true },
),
});
assert.equal(delivered.err, undefined);
await peerOf(smpp).close();
await closed;
assert.deepEqual(events, ['disconnected', 'close']);
});
test('close() still waits for a concatenated message the application has not answered', async t => { test('close() still waits for a concatenated message the application has not answered', async t => {
const smpp = await startServer(t, { shutdownTimeout: 50 }); const smpp = await startServer(t, { shutdownTimeout: 50 });
const incoming = once<Sms>(resolve => { const incoming = once<Sms>(resolve => {
+42 -14
View File
@@ -200,19 +200,38 @@ next work ([decision](docs/decisions.md#internals-and-tests)).
### Locality — next, ahead of everything below; 5–6 today, and the gate is 7 ### Locality — next, ahead of everything below; 5–6 today, and the gate is 7
A second four-seat run on 2026-09-28, after #35–#40, read 6, 6, 7 and 6 again, Locality 5, 5, 6 A second four-seat run on 2026-09-28, after #35–#40, read 6, 6, 7 and 6 again, Locality 5, 5, 6
and 5. Every seat ranked the session's lifecycle hardest and least wanted to modify it. and 5. Every seat ranked the session's lifecycle hardest and least wanted to modify it. A third,
after #46, read 6, 6, 7 and 7, Locality 5, 5, 6 and 6. A fourth, after the link's liveness got one
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.
- [ ] **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 and #46 decision. The sub-items are what the 2026-09-28 run named, most seats first. retires the #30, #46 and #48 decision.
- [ ] **Give the link's liveness one owner.** A third run the same day, after #46, read 6, 6, 7 and
7, Locality 5, 5, 6 and 6; all four seats ranked `drain()`/`end()`/`teardown()`/`retrying()` - [ ] **Give the held-message flow one owner — next, the condition #48 merged under.** Whether a
in `session.ts` hardest, because whether the link lives is kept in `Session.lifecycle`, message is still held, and so whether its receipt may pass the drain, is decided across
`ReconnectLoop.halted`, `LinkGate.up`/`returning`, `OutgoingRequests.draining` and `IncomingRequests.emitSms()`, `HeldMessages.offer()`/`MessageHold`, `createSms()`'s handlers in
`IncomingRequests.linkGeneration`, held in step by statement order and the comment above `sms.ts` and `Session`'s rejection handler, which finds the hold again through a `WeakMap`
`retrying()`. Four seats. 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.
### Correctness ### Correctness
- [ ] **Refuse to open a link that dropped while its rebind was answered.** A peer sending
`bind_resp` and FIN together can tear the link down before `comeBackUp()` resumes; it then
calls `link.open()` on a `down` link, `resetTimers()` skips, and `attempt()` reports success,
so the session is `up` on a destroyed socket with nothing to reconnect it until `close()`.
`open()` accepting only `binding`, and `comeBackUp()` returning an err when the link is no
longer attached, lets the loop retry. Unreproduced; from the stability review of #48.
- [ ] **Register a multipart send's receipt merge before its segments go out.** `Session.sendSms()`
calls `dlrMerger.expect()` only once `submitSms()` resolves, after the last segment's response,
so a receipt for an early segment that arrives first is logged at `debug` as naming no merge,
and the group then waits out `dlrMergeTimeout` with no `messageDlr`. Likeliest with a fast SMSC
or more segments than `maxOutstanding`. Goal 2. From the 2026-09-28 scoring run on #48.
- [ ] **Settle what a repeated tag not marked `multiple` reads as, and pin it in a test.** A vendor - [ ] **Settle what a repeated tag not marked `multiple` reads as, and pin it in a test.** A vendor
tag or a known single-value tag a peer sends twice keeps the last occurrence and drops the tag or a known single-value tag a peer sends twice keeps the last occurrence and drops the
rest silently, which goal 3 argues against; listing it would change every such tag's shape. rest silently, which goal 3 argues against; listing it would change every such tag's shape.
@@ -367,11 +386,20 @@ and 5. Every seat ranked the session's lifecycle hardest and least wanted to mod
`error`-event reason (hard rule 3 owns it) and the Audience bullets restating goal 8 and `error`-event reason (hard rule 3 owns it) and the Audience bullets restating goal 8 and
Install; the `'use strict'` clause in both MIGRATION.md and CHANGELOG.md; the node-smpp Install; the `'use strict'` clause in both MIGRATION.md and CHANGELOG.md; the node-smpp
cross-check in MIGRATION.md; the planned work in `interop-tests/AGENTS.md` (an expected cross-check in MIGRATION.md; the planned work in `interop-tests/AGENTS.md` (an expected
malformed count per peer) and `benchmarks/README.md`. malformed count per peer) and `benchmarks/README.md`. The prose sweep of #48 adds: the smppload
note in both `benchmarks/README.md` and `interop-tests/README.md`; the summary after the
`AGENTS.md` link in `interop-tests/README.md`; the `run.py` foreground rule tacked onto rule 5 in
`interop-tests/AGENTS.md`, which wants its own number.
- [ ] **Make `LinkGate.isUp()`'s doc true or its state match it.** It says a link attached but not - [ ] **Give this library one figure at window 50 in `benchmarks/README.md`.** Its "same sink, same
yet bound cannot carry a request, while `up` starts `true`, so the first link and a server host" table reads 37,125/s where the table below it reads 38,675/s; re-measure or cite one run.
session are up before any bind. The gate decision in `docs/decisions.md` makes the same claim
- [ ] **Name the goal and the premise of every `docs/decisions.md` entry.** The prose sweep of #48
counted 30 of 58 entries naming no goal and 52 with no "valid while" premise.
- [ ] **Make `LinkLife` start unbound, or its decision's title true.** A link attached but not
yet bound cannot carry a request, while `phase` starts `up`, so the first link and a server
session are up before any bind. The `LinkLife` decision in `docs/decisions.md` makes the same claim
in its title, and carries the same fix. From the comprehension panel of #25. in its title, and carries the same fix. From the comprehension panel of #25.
- [ ] **Move `checkSessionOptions()`'s doc comment to what it describes.** It explains why a count - [ ] **Move `checkSessionOptions()`'s doc comment to what it describes.** It explains why a count
@@ -485,9 +513,9 @@ and 5. Every seat ranked the session's lifecycle hardest and least wanted to mod
support Gitea, so mirror each Gitea pull request to GitHub for it to review there. support Gitea, so mirror each Gitea pull request to GitHub for it to review there.
Maintainer's ask, 2026-09-14; not started until asked. Maintainer's ask, 2026-09-14; not started until asked.
- [ ] **Count what is left of a budget one way in `leftOf()` and the link gate.** Today they are one - [ ] **Count what is left of a budget one way in `leftOf()` and `LinkLife`.** Today they are one
concept counted twice. `idle-waiters.ts` reads what is left of a budget as `Math.max(1, concept counted twice. `idle-waiters.ts` reads what is left of a budget as `Math.max(1,
deadline - now)`, because 0 means "forever" there; `link-gate.ts` runs the same subtraction deadline - now)`, because 0 means "forever" there; `link-life.ts` runs the same subtraction
and calls `<= 0` expired. Neither is reachable from the other, so nothing can disagree today, and calls `<= 0` expired. Neither is reachable from the other, so nothing can disagree today,
but a reader who learns one and applies it to the other is wrong. A budget type both take but a reader who learns one and applies it to the other is wrong. A budget type both take
would close it. Raised by review, 2026-09-01. would close it. Raised by review, 2026-09-01.