Cut the prose the sweep of #48 found restated or false
Mirror / push (push) Successful in 8s
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 (22) (pull_request) Successful in 33s
Test / test (24) (pull_request) Successful in 32s
Test / test (26) (pull_request) Successful in 31s
Mirror / push (push) Successful in 8s
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 (22) (pull_request) Successful in 33s
Test / test (24) (pull_request) Successful in 32s
Test / test (26) (pull_request) Successful in 31s
This commit is contained in:
@@ -13,9 +13,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) — they say where this library is heading, which an outside
|
||||
reader judges it by. The README states the audience alongside them.
|
||||
|
||||
[README.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/README.md#goals). The README states the audience alongside them.
|
||||
|
||||
## Hard rules
|
||||
|
||||
@@ -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`,
|
||||
`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.
|
||||
- `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
|
||||
socket never processes the peer's FIN, so `server.close()` hangs forever — that is a test bug, not
|
||||
a library one.
|
||||
@@ -314,7 +311,7 @@ the file.
|
||||
- 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 queued for a send-window slot is bounded by the caller's `signal`, and by nothing else.
|
||||
- `LinkLife` 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)
|
||||
|
||||
|
||||
@@ -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
|
||||
between instances is for goal 9's store, which has not shipped: today every session keeps its own,
|
||||
in memory.
|
||||
- **Pre-1.0, so the minor is the breaking unit** and a patch never breaks. What a 0.4.0 consumer has
|
||||
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).
|
||||
- **Pre-1.0, so the minor is the breaking unit** and a patch never breaks.
|
||||
|
||||
Personas this README serves, in order:
|
||||
|
||||
|
||||
+11
-23
@@ -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`.**
|
||||
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
|
||||
`LinkLife.retrying()`, and `end()` stops the session before tearing down, so every deliberate
|
||||
shutdown emits `close`. A retry that opens a socket and then loses it is attached again through
|
||||
`attach()`, which is why a second drop emits again.
|
||||
`close` ends up holding two binds on one account, which goal 4 forbids.
|
||||
|
||||
- **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
|
||||
@@ -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
|
||||
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`
|
||||
would be a lie. Its wait is the one retry timer that is not `unref()`'d, for the reason
|
||||
`LinkLife`'s hold is not — it is awaited with no other handle, so a process whose only work is
|
||||
`client()` would exit unbound.
|
||||
would be a lie.
|
||||
|
||||
- **`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
|
||||
@@ -739,9 +734,7 @@ rule and an index of the titles below.
|
||||
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
|
||||
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
|
||||
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.
|
||||
every hold a single call makes.
|
||||
|
||||
- **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
|
||||
@@ -760,19 +753,14 @@ rule and an index of the titles below.
|
||||
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.
|
||||
|
||||
- **`LinkLife` 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
|
||||
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`. `LinkLife` 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. Every other collaborator reads whether the link lives from it and keeps no copy: five
|
||||
copies held in step by statement order were what the 2026-09-28 comprehension runs ranked hardest.
|
||||
`ReconnectLoop.halted` is the loop's own, for its timer, because `client()` also runs a loop with
|
||||
no session behind it for `fromStart`; a session's loop is stopped by `Session.stop()` alone.
|
||||
The retry in `requestPastDrain()` asks `link.awaitsNextLink()` rather than `canCarry()`, which also
|
||||
reads the socket: a loop condition the link does not gate on spins against a link that admits it
|
||||
straight back.
|
||||
- **One owner decides whether a link can carry a request, and a bind is what makes it one.**
|
||||
Maintainer's call, 2026-09-01, extended 2026-09-28; goal 1, since a send on a link not yet bound
|
||||
comes back `ESME_RINVBNDSTS`. `LinkLife` is told what happened and never reads back into the
|
||||
session; every other collaborator reads it and keeps no copy. Rejected: gating on the socket being
|
||||
attached, which admits a send one round trip before the bind is answered, and collaborators that
|
||||
ask the session, which answered the same question two ways at admit and at release.
|
||||
`ReconnectLoop.halted` is the one other flag, because `client()` also runs a loop with no session
|
||||
behind it for `fromStart`; a session's loop is stopped by `Session.stop()` alone.
|
||||
|
||||
|
||||
## Internals and tests
|
||||
|
||||
+3
-3
@@ -10,7 +10,7 @@ export type LinkLifeOptions = {
|
||||
timeout: number;
|
||||
};
|
||||
|
||||
/** `binding`: a socket is attached and its bind is not answered yet, so it carries no request. */
|
||||
/** `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;
|
||||
@@ -69,7 +69,7 @@ export class LinkLife {
|
||||
return this.reconnects && !this.stopped;
|
||||
}
|
||||
|
||||
/** Down, with another link on its way. */
|
||||
/** Not up and not over, with a link to come. */
|
||||
awaitsNextLink(): boolean {
|
||||
return !this.isUp() && !this.isOver() && this.retrying();
|
||||
}
|
||||
@@ -84,7 +84,7 @@ export class LinkLife {
|
||||
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> {
|
||||
const deadline = this.timeout > 0 ? this.now() + this.timeout : 0;
|
||||
|
||||
|
||||
@@ -69,7 +69,6 @@ export class OutgoingRequests {
|
||||
this.pending.settle(seqNr, { err });
|
||||
}
|
||||
|
||||
/** Sends a request and resolves with the peer's response. */
|
||||
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.
|
||||
const wrong = misuse(input);
|
||||
@@ -136,7 +135,7 @@ export class OutgoingRequests {
|
||||
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 {
|
||||
// Until the link is dropped it admits the retry straight back onto the dead socket, and the loop spins.
|
||||
return attempt.retryOnNextLink && this.link.awaitsNextLink();
|
||||
|
||||
+4
-4
@@ -247,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
|
||||
* `shutdownTimeout`, then tears down whatever is left. A session closed this way never reconnects.
|
||||
* Closes for good: refuses new sends, waits up to `shutdownTimeout` for the requests already sent
|
||||
* 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> {
|
||||
const drained = await this.drain(options.signal);
|
||||
@@ -324,7 +324,7 @@ export class Session extends EventEmitter<SessionEvents> {
|
||||
private async drain(signal: AbortSignal | undefined): Promise<VoidResult> {
|
||||
this.stop();
|
||||
|
||||
// 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 {};
|
||||
|
||||
const timeout = this.options.shutdownTimeout ?? defaults.shutdownTimeout;
|
||||
@@ -385,7 +385,7 @@ export class Session extends EventEmitter<SessionEvents> {
|
||||
this.incoming.clear();
|
||||
this.sock.destroy();
|
||||
|
||||
// Not re-read: clear() reports lost segments, and a listener can stop the session in between.
|
||||
// `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();
|
||||
}
|
||||
|
||||
@@ -378,7 +378,16 @@ hardest, and the held-message flow across `incoming-requests.ts`, `held-messages
|
||||
`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
|
||||
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.
|
||||
|
||||
- [ ] **Give this library one figure at window 50 in `benchmarks/README.md`.** Its "same sink, same
|
||||
host" table reads 37,125/s where the table below it reads 38,675/s; re-measure or cite one run.
|
||||
|
||||
- [ ] **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
|
||||
|
||||
Reference in New Issue
Block a user