diff --git a/src/link-life.ts b/src/link-life.ts index 558fee4..6bcaf2e 100644 --- a/src/link-life.ts +++ b/src/link-life.ts @@ -107,14 +107,14 @@ export class LinkLife { this.release({}); } - /** The attached link is gone. False means there was none to lose. */ - drop(): boolean { - if (!this.isAttached()) return false; + /** The attached link is gone: the event that says so, or undefined when there was none to lose. */ + drop(): 'close' | 'disconnected' | undefined { + if (!this.isAttached()) return undefined; this.phase = 'down'; this.drops++; - return true; + return this.retrying() ? 'disconnected' : 'close'; } stop(): void { diff --git a/src/outgoing-requests.ts b/src/outgoing-requests.ts index fc09eb0..2180460 100644 --- a/src/outgoing-requests.ts +++ b/src/outgoing-requests.ts @@ -97,7 +97,7 @@ export class OutgoingRequests { if (bindCommands.includes(input.cmdName)) { const shut = this.link.refusal(); - return shut ? { err: shut } : this.requestPastDrainGateAndWindow(input, options); + return shut ? { err: shut } : this.requestOnCurrentLink(input, options); } const waitForLink = this.link.hold(options.signal); @@ -118,7 +118,7 @@ export class OutgoingRequests { } /** Straight onto the current link, for what has to go out either way. */ - async requestPastDrainGateAndWindow( + async requestOnCurrentLink( input: PduObjectInput, options: SendOptions = {}, ): Promise> { diff --git a/src/session.ts b/src/session.ts index 1969026..1a1617d 100644 --- a/src/session.ts +++ b/src/session.ts @@ -237,7 +237,7 @@ export class Session extends EventEmitter { const drained = await this.drain(undefined); const wasOpen = this.link.isAttached(); const sent = wasOpen - ? await this.outgoing.requestPastDrainGateAndWindow({ cmdName: 'unbind' }) + ? await this.outgoing.requestOnCurrentLink({ cmdName: 'unbind' }) : { err: new Error('Session is closed') }; const closedOnUnbind = wasOpen && !this.link.isAttached(); @@ -376,17 +376,17 @@ export class Session extends EventEmitter { } private teardown(): void { - // Read once: clear() reports lost segments, and a listener could stop the session between reads. - const retrying = this.link.retrying(); + const lost = this.link.drop(); - if (!this.link.drop()) return; + if (!lost) return; this.outgoing.linkLost(); this.timers.clear(); this.incoming.clear(); this.sock.destroy(); - if (retrying) this.emit('disconnected'); + // Not re-read: clear() reports lost segments, and a listener can stop the session in between. + if (lost === 'disconnected') this.emit('disconnected'); else this.emitClose(); } diff --git a/test/session-extras.test.ts b/test/session-extras.test.ts index c130236..682ac31 100644 --- a/test/session-extras.test.ts +++ b/test/session-extras.test.ts @@ -1460,19 +1460,20 @@ describe('LinkLife', () => { assert.match(link.refusal()?.message ?? '', /closed/, 'stopped while down'); link.end(); assert.equal(link.awaitsNextLink(), false, 'ended'); - assert.equal(new LinkLife({ log: silentLog, reconnects: false, timeout: 100 }).drop(), true); }); - test('drops an attached link once, and counts each drop', () => { + 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(), true); - assert.equal(link.drop(), false, 'already down'); + assert.equal(link.drop(), 'disconnected'); + assert.equal(link.drop(), undefined, 'already down'); assert.equal(link.generation(), generation + 1); link.attach(); - assert.equal(link.drop(), true, 'a new link drops again'); + 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 () => { diff --git a/todo.md b/todo.md index a4f01c6..def24e5 100644 --- a/todo.md +++ b/todo.md @@ -201,13 +201,22 @@ next work ([decision](docs/decisions.md#internals-and-tests)). 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. A third, -after #46, read 6, 6, 7 and 7, Locality 5, 5, 6 and 6; the link's liveness now has one owner. +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 retires the #30 and #46 decision. ### Correctness +- [ ] **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 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.