Name carry()'s retry condition and what ends its loop #39

Merged
lilleman merged 4 commits from name-past-drain into main 2026-09-28 02:00:33 +02:00
6 changed files with 37 additions and 22 deletions
+6 -10
View File
@@ -761,16 +761,12 @@ rule and an index of the titles below.
round trip before the bind is answered, so gating on `closed` let a send arriving in that window go 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)` 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 on every teardown, `open()` only once `comeBackUp()` has a bound link — and
`OutgoingRequests.canCarry()` reads it rather than `closed`. The bind itself cannot wait for what it `OutgoingRequests.canCarry()` reads it rather than `closed`. The gate is told what happened and
creates, so `pastDrain()` lets the three bind commands past the gate and the window, the same door never reads back into the session: a collaborator that has to ask does not own its decision, which
`unbind()` takes through `now()`. The gate is told what happened and never reads back into the is how the first cut ended up answering the same question two different ways at admit and at
session: a collaborator that has to ask does not own its decision, which is how the first cut ended release. The retry in `carry()` asks `gate.awaitsNextLink()` rather than `canCarry()`, which also
up answering the same question two different ways at admit and at release. For the same reason the reads the socket: a loop condition the gate does not gate on spins against a gate that admits it
retry in `pastDrain()` asks `gate.isUp()` rather than `canCarry()`, which also reads the socket — a straight back.
condition that loops on something the gate does not gate on spins against a gate that admits it
straight back. `LinkGate.returning` is a copy of `retrying()` taken at teardown, and stays true
only because nothing stops the reconnect loop without `emitClose()` following it: `drain()` and
`end()` are the only callers of `stop()`. A third caller has to shut the gate itself.
## Internals and tests ## Internals and tests
+5
View File
@@ -42,6 +42,11 @@ export class LinkGate {
return this.up; return this.up;
} }
/** Shut, with another link on its way to reopen it. */
awaitsNextLink(): boolean {
return !this.up && this.returning;
}
/** Why the gate will never admit a request, or undefined while one may still get through. */ /** Why the gate will never admit a request, or undefined while one may still get through. */
refusal(): Error | undefined { refusal(): Error | undefined {
return this.up || this.returning ? undefined : over(); return this.up || this.returning ? undefined : over();
+10 -5
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.pastDrain(input, options); return this.carry(input, options);
} }
/** The same path without that refusal, which a receipt for a held message has to take. */ /** The same path without the drain's refusal, which a receipt for a held message has to take. */
async pastDrain( async carry(
input: PduObjectInput, input: PduObjectInput,
options: SendOptions, options: SendOptions,
): Promise<Result<{ pduObj: PduObject }>> { ): Promise<Result<{ pduObj: PduObject }>> {
@@ -125,8 +125,7 @@ export class OutgoingRequests {
const attempt = await this.attempt(input, options).finally(() => { this.window.release(); }); const attempt = await this.attempt(input, options).finally(() => { this.window.release(); });
// Nothing reached the socket, so the next link carries it instead of the caller resending. if (!this.retriesOnNextLink(attempt)) return attempt.result;
if (!attempt.retryOnNextLink || this.gate.isUp() || this.gate.refusal()) return attempt.result;
} }
} }
@@ -151,6 +150,12 @@ 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. */
private retriesOnNextLink(attempt: Attempt): boolean {
// Until the gate is shut it admits the retry straight back onto the dead socket, and the loop spins.
return attempt.retryOnNextLink && this.gate.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 gate and the window, or an aborted call waits for what it will never use.
+2 -1
View File
@@ -261,7 +261,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.pastDrain(input, {}), sendPastDrain: input => this.outgoing.carry(input, {}),
smsListeners: () => this.listenerCount('sms'), smsListeners: () => this.listenerCount('sms'),
}; };
} }
@@ -395,6 +395,7 @@ export class Session extends EventEmitter<SessionEvents> {
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 { private retrying(): boolean {
return this.reconnectLoop !== undefined && !this.reconnectLoop.isStopped(); return this.reconnectLoop !== undefined && !this.reconnectLoop.isStopped();
} }
+12
View File
@@ -1448,6 +1448,18 @@ describe('LinkGate', () => {
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', () => {
const gate = new LinkGate({ log: silentLog, timeout: 100 });
assert.equal(gate.awaitsNextLink(), false, 'up');
gate.shut(true);
assert.equal(gate.awaitsNextLink(), true, 'shut, returning');
gate.open();
assert.equal(gate.awaitsNextLink(), false, 'reopened');
gate.shut(false);
assert.equal(gate.awaitsNextLink(), false, 'shut for good');
});
}); });
describe('SendWindow', () => { describe('SendWindow', () => {
+2 -6
View File
@@ -199,11 +199,6 @@ 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
- [ ] **Name `pastDrain()`'s retry condition and what makes the loop end.** The exit is a
three-term disjunction over two collaborators, whose comment covers the first term only, and
the method is named for what it bypasses. Do not change what it asks: `gate.isUp()` rather than
`canCarry()` is deliberate and recorded.
- [ ] **Replace `resolveBody`'s `settles` boolean with the decision it stands for.** One boolean - [ ] **Replace `resolveBody`'s `settles` boolean with the decision it stands for.** One boolean
chooses both whether to overwrite `data_coding` and which params to read it from, across four chooses both whether to overwrite `data_coding` and which params to read it from, across four
helpers all named some abstraction of "body". Return a named source — `'short_message' | helpers all named some abstraction of "body". Return a named source — `'short_message' |
@@ -326,7 +321,8 @@ next work ([decision](docs/decisions.md#internals-and-tests)).
- [ ] **Make `LinkGate.isUp()`'s doc true or its state match it.** It says a link attached but not - [ ] **Make `LinkGate.isUp()`'s doc true or its state match it.** It says a link attached but not
yet bound cannot carry a request, while `up` starts `true`, so the first link and a server yet bound cannot carry a request, while `up` starts `true`, so the first link and a server
session are up before any bind. From the comprehension panel of #25. session are up before any bind. The gate decision in `docs/decisions.md` makes the same claim
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
below 1 is refused, which is `checkLimits`' job, and says nothing of the function it heads. below 1 is refused, which is `checkLimits`' job, and says nothing of the function it heads.