From 3aadb64df8c81776896b1a30a7b3ba63af67e75e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikael=20=27Lilleman=27=20G=C3=B6ransson?= Date: Wed, 2 Sep 2026 08:15:26 +0200 Subject: [PATCH 1/4] Release a message's hold when the response goes out, or when its listener failed --- AGENTS.md | 6 +++++- README.md | 2 +- src/incoming-requests.ts | 10 ++++++++++ src/session.ts | 4 +++- src/sms.ts | 10 ++++++---- test/session-extras.test.ts | 39 +++++++++++++++++++++++++++++++++++++ todo.md | 13 +------------ 7 files changed, 65 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8e083bc..bb7369c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -351,7 +351,11 @@ Grouped by what each one constrains. drain waits for. Counting every inbound request until `sendReturn()` answered it was rejected — an `onRequest` that deliberately answers nothing would then cost a full `shutdownTimeout` on every close — and a message no listener took is released at once, since nothing is going to answer it. - `teardown()` drops what is still held for the same reason it drops inbound segments. The release + A listener that threw or rejected before answering releases it the same way, for the same reason, + with `sessionError` carrying the failure. What ends the wait is the response reaching the wire, not + the call: a `sendResp()` the library refused leaves the message held, so `close()` still reports the + one the peer is owed. `teardown()` drops what is still held for the same reason it drops inbound + segments. The release is one turn late, so a listener that sends its receipt straight after the response is still holding when the drain looks; `sendDlr()` is the one send that goes out past the drain's refusal, and only while the message is still held — past that it is an ordinary send, because the drain it diff --git a/README.md b/README.md index 48108aa..fb3d8de 100644 --- a/README.md +++ b/README.md @@ -332,7 +332,7 @@ TypeScript users can import `SmppLog` to have the compiler check one. `sendSms()`, `send()`, `sendReturn()`, `unbind()` and `close()`. Both `close()` and `unbind()` refuse further sends, wait out the requests this end already sent for up to `shutdownTimeout`, and then tear down whatever is left, resolving to an `err` that says what was lost. They also wait for -every `sms` the application has not called `sendResp()` on, so a peer whose `submit_sm` is still +every `sms` that `sendResp()` has not answered, so a peer whose `submit_sm` is still being handled is answered rather than left to re-send it — answering its PDUs through `sendReturn()` instead leaves that wait running until it gives up. `sendDlr()` is the one send the refusal lets past, and it catches the wait when issued straight after `sendResp()`; await anything in between and diff --git a/src/incoming-requests.ts b/src/incoming-requests.ts index 547015a..fca8fa7 100644 --- a/src/incoming-requests.ts +++ b/src/incoming-requests.ts @@ -39,6 +39,8 @@ export class IncomingRequests { private readonly session: Session; private readonly smsIdFormat: SmsIdFormat; private readonly systemId: string; + /** Identity, not a type guard: the rejected event carries the Sms back as an `unknown`. */ + private readonly emitted = new WeakMap void>(); private linkGeneration = 0; constructor(options: IncomingRequestsOptions) { @@ -111,6 +113,13 @@ export class IncomingRequests { this.reassembler.clear(); } + /** A listener that rejected answered nothing and never will, so a shutdown may not wait for it. */ + listenerRejected(sms: unknown): void { + if (typeof sms !== 'object' || sms === null) return; + + this.emitted.get(sms)?.(); + } + /** Waits out the messages the application still holds, and says how many it never answered. */ async drain(timeout: number, signal: AbortSignal | undefined): Promise { const unanswered = await this.held.idle(timeout, signal); @@ -191,6 +200,7 @@ export class IncomingRequests { }); this.held.hold(pduObjs); + this.emitted.set(sms, () => { this.held.release(pduObjs); }); // A message nobody took is not work a shutdown can wait for. if (!this.session.emit('sms', sms)) this.held.release(pduObjs); diff --git a/src/session.ts b/src/session.ts index 34868e9..9097030 100644 --- a/src/session.ts +++ b/src/session.ts @@ -93,11 +93,13 @@ export class Session extends EventEmitter { reason: unknown, ...args: [event: keyof SessionEvents, ...rest: unknown[]] ): void { - const [event] = args; + const [event, ...rest] = args; const error = errorFrom(reason); this.log.error('session - a listener rejected', { event, message: error.message }); + if (event === 'sms') this.incoming.listenerRejected(rest[0]); + if (event !== 'sessionError') this.emit('sessionError', error); } diff --git a/src/sms.ts b/src/sms.ts index 8b74b1d..750ea02 100644 --- a/src/sms.ts +++ b/src/sms.ts @@ -77,8 +77,7 @@ export function createSms(input: SmsInput, handlers: SmsHandlers): Sms { message: input.message, pduObjs: input.pduObjs, sendDlr: status => sendDlr(sms, handlers.send, status), - sendResp: options => sendResp(sms, answered, options ?? {}, handlers.lostLink) - .finally(handlers.onAnswered), + sendResp: options => sendResp(sms, answered, options ?? {}, handlers), session: input.session, get smsId(): string { return answered.smsId; @@ -94,7 +93,7 @@ async function sendResp( sms: Sms, answered: { smsId: string }, options: SendRespOptions, - lostLink: () => boolean, + handlers: SmsHandlers, ): Promise { const total = sms.pduObjs.length; @@ -109,7 +108,7 @@ async function sendResp( if (options.smsId !== undefined) answered.smsId = options.smsId; // A response carries the sequence number it was asked on, which the next link knows nothing about. - if (lostLink()) { + if (handlers.lostLink()) { return { err: new Error('The link this message arrived on is gone, so nothing would correlate the response') }; } @@ -119,6 +118,9 @@ async function sendResp( { message_id: segmentId(answered.smsId, index, total) }, ))); + // Only here: a refusal above put nothing on the wire, so the peer is still owed its response. + handlers.onAnswered(); + return results.find(result => result.err) ?? {}; } diff --git a/test/session-extras.test.ts b/test/session-extras.test.ts index 1608de2..f6b9212 100644 --- a/test/session-extras.test.ts +++ b/test/session-extras.test.ts @@ -1401,6 +1401,45 @@ describe('graceful shutdown', () => { assert.ok((await sent).err instanceof Error); }); + // emit() releases the hold of a listener that throws; one that rejects may cost no more than that. + test('a listener that rejected before answering does not hold the shutdown up', async t => { + const smpp = await startServer(t, { shutdownTimeout: 30_000 }); + const failed = once(resolve => { + smpp.on('session', bound => { + bound.on('sessionError', resolve); + bound.on('sms', () => Promise.reject(new Error('the listener gave up'))); + }); + }); + const { session } = await connect(t, smpp); + + assert.ok(session); + + const sent = session.sendSms({ + from: '46701113311', + message: 'the listener rejects', + to: '46709771337', + }); + + assert.equal((await failed).message, 'the listener gave up'); + + const started = Date.now(); + + assert.deepEqual(await peerOf(smpp).close(), {}); + assert.ok(Date.now() - started < 1000); + assert.ok((await sent).err instanceof Error); + }); + + // Nothing reached the peer, so a drain counting this answered would report an outcome that never was. + test('leaves a message the library refused to answer unanswered', async t => { + const { sms, smpp } = await submitInFlight(t, {}, { shutdownTimeout: 50 }); + const refused = await sms.sendResp({ smsId: '' }); + const closed = await peerOf(smpp).close(); + + assert.match(refused.err?.message ?? '', /smsId must not be empty/); + assert.ok(closed.err instanceof Error); + assert.match(closed.err.message, /1 message\(s\) unanswered/); + }); + test('gives up on a request that outlasts shutdownTimeout', async t => { const { sent, session } = await submitInFlight(t, { shutdownTimeout: 50 }); const closed = await session.close(); diff --git a/todo.md b/todo.md index 7762c37..b583cce 100644 --- a/todo.md +++ b/todo.md @@ -61,6 +61,7 @@ Rules the API follows: | Held messages capped and expiring, so an application that answers nothing cannot grow them | `test/session-extras.test.ts` | | A send with no link held for the next one, and one the link dropped under counted as `unanswered` | `test/session-extras.test.ts` | | A message whose link dropped refused an answer, with its receipt still allowed out | `test/session-extras.test.ts` | +| The hold released exactly when the peer was answered: a refused `sendResp()` keeps it, a listener that rejected drops it | `test/session-extras.test.ts` | | Every runnable README example | `test/readme.test.ts` | | Receipt-versus-message classification by `esm_class` | `test/dlr.test.ts`, `test/session.test.ts` | | A listener that throws, or rejects, reaching `sessionError`/`serverError` rather than the process | `test/session.test.ts`, `test/error-from.test.ts` | @@ -134,18 +135,6 @@ session message is a change to every call site. and applies it to the other is wrong. A budget type both take would close it. Raised by review, 2026-09-01. -- [ ] **An `sms` listener that rejects before answering costs a whole `shutdownTimeout`.** - One that *throws* is fine: `emit()` catches it, returns false, and `emitSms()` releases the - hold. A rejecting `async` one reaches `sessionError` through `captureRejections`, which hands - the handler an `unknown[]` the `Sms` cannot be read out of without a cast, so nothing releases - until the message expires. Same cost as the `onRequest`-answers-nothing case that was declined, - but reached by a bug rather than a policy. Raised by review, 2026-09-01. - -- [ ] **A refused `sendResp()` releases the hold anyway.** Every early return runs - `.finally(onAnswered)`, so `sendResp({ smsId: '' })` refuses and stops the drain waiting for a - message the peer was never answered, which `close()` then reports as answered. Raised by - review, 2026-09-01. - - [ ] **Does an intermediate delivery notification deserve to be a `dlr`?** `esm_class` message type `INTERMEDIATE_DELIVERY` (0x20) is classified as a message today, so a peer that reports non-final states with it hands the application a raw `id:… stat:ENROUTE` text as an inbound From 3eeb0b82c0c633f80e4c4fd9230215b0f9e6adbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikael=20=27Lilleman=27=20G=C3=B6ransson?= Date: Wed, 2 Sep 2026 12:36:01 +0200 Subject: [PATCH 2/4] Give a message up only once every listener has, and only count a response the wire took --- AGENTS.md | 19 +++++++------ README.md | 7 +++-- src/incoming-requests.ts | 20 ++++++++++---- src/sms.ts | 9 +++--- test/session-extras.test.ts | 55 +++++++++++++++++++++++++++++++++++++ 5 files changed, 88 insertions(+), 22 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bb7369c..a4210b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -351,15 +351,16 @@ Grouped by what each one constrains. drain waits for. Counting every inbound request until `sendReturn()` answered it was rejected — an `onRequest` that deliberately answers nothing would then cost a full `shutdownTimeout` on every close — and a message no listener took is released at once, since nothing is going to answer it. - A listener that threw or rejected before answering releases it the same way, for the same reason, - with `sessionError` carrying the failure. What ends the wait is the response reaching the wire, not - the call: a `sendResp()` the library refused leaves the message held, so `close()` still reports the - one the peer is owed. `teardown()` drops what is still held for the same reason it drops inbound - segments. The release - is one turn late, so a listener that sends its receipt straight after the response is still - holding when the drain looks; `sendDlr()` is the one send that goes out past the drain's refusal, - and only while the message is still held — past that it is an ordinary send, because the drain it - would slip past is no longer waiting for it. `shutdownTimeout: 0` does not carry over to this half: + A listener that failed before answering gives it up the same way, but only once every listener has: + a throw stops `emit()` where it stands, while a rejection leaves the others running, so the release + waits for the last of them rather than answering on their behalf. What ends the wait is the response + reaching the wire, not the call — a `sendResp()` the library refused, or one the socket would not + carry, leaves the message held, so `close()` still reports the one the peer is owed. `teardown()` + drops what is still held for the same reason it drops inbound segments. The release is one turn + late, so a listener that sends its receipt straight after the response is still holding when the + drain looks; `sendDlr()` is the one send that goes out past the drain's refusal, and only while the + message is still held — past that it is an ordinary send, because the drain it would slip past is + no longer waiting for it. `shutdownTimeout: 0` does not carry over to this half: waiting forever is safe for 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 the application is stuck, so it may not block on the application coming diff --git a/README.md b/README.md index fb3d8de..9d879ae 100644 --- a/README.md +++ b/README.md @@ -332,9 +332,10 @@ TypeScript users can import `SmppLog` to have the compiler check one. `sendSms()`, `send()`, `sendReturn()`, `unbind()` and `close()`. Both `close()` and `unbind()` refuse further sends, wait out the requests this end already sent for up to `shutdownTimeout`, and then tear down whatever is left, resolving to an `err` that says what was lost. They also wait for -every `sms` that `sendResp()` has not answered, so a peer whose `submit_sm` is still -being handled is answered rather than left to re-send it — answering its PDUs through `sendReturn()` -instead leaves that wait running until it gives up. `sendDlr()` is the one send the refusal lets +every `sms` still in the application's hands, so a peer whose `submit_sm` is +being handled is answered rather than left to re-send it. That wait ends when `sendResp()` puts the +response on the wire, or when every listener that took the message has failed; answering its PDUs +through `sendReturn()` instead leaves the wait running until it gives up. `sendDlr()` is the one send the refusal lets past, and it catches the wait when issued straight after `sendResp()`; await anything in between and it races the shutdown like any other send. `close({ signal })` takes an `AbortSignal` that cuts the wait short; `unbind()` takes none, and waits a further diff --git a/src/incoming-requests.ts b/src/incoming-requests.ts index fca8fa7..e4208a2 100644 --- a/src/incoming-requests.ts +++ b/src/incoming-requests.ts @@ -31,6 +31,8 @@ export type IncomingRequestsOptions = { /** Everything the peer asks of a session: messages, receipts, links and the answers to them. */ export class IncomingRequests { private readonly dlrMerger: DlrMerger; + /** The rejection handler is handed the Sms back as an `unknown`, so its hold is found by identity. */ + private readonly emitted = new WeakMap void>(); private readonly held: HeldMessages; private readonly log: SmppLog; private readonly onRequest: OnRequest | undefined; @@ -39,8 +41,6 @@ export class IncomingRequests { private readonly session: Session; private readonly smsIdFormat: SmsIdFormat; private readonly systemId: string; - /** Identity, not a type guard: the rejected event carries the Sms back as an `unknown`. */ - private readonly emitted = new WeakMap void>(); private linkGeneration = 0; constructor(options: IncomingRequestsOptions) { @@ -113,7 +113,7 @@ export class IncomingRequests { this.reassembler.clear(); } - /** A listener that rejected answered nothing and never will, so a shutdown may not wait for it. */ + /** One `sms` listener gave up on a message; the last one to do so is what releases the hold. */ listenerRejected(sms: unknown): void { if (typeof sms !== 'object' || sms === null) return; @@ -184,6 +184,8 @@ export class IncomingRequests { if (!first) return; const generation = this.linkGeneration; + // A turn later, so a listener sending its receipt straight after the response still holds. + const release = (): void => { setImmediate(() => { this.held.release(pduObjs); }); }; const sms = createSms({ from: paramText(first.params.source_addr), @@ -193,14 +195,20 @@ export class IncomingRequests { to: paramText(first.params.destination_addr), }, { lostLink: () => this.linkGeneration !== generation, - // A turn later, so a listener sending its receipt straight after the response still holds. - onAnswered: () => { setImmediate(() => { this.held.release(pduObjs); }); }, + onAnswered: release, // Past the refusal only while a drain is still waiting for this message; an ordinary send after. send: input => (this.held.has(pduObjs) ? this.sendPastDrain(input) : this.session.send(input)), }); + // A rejection leaves the other listeners running, so only the last one to fail gives the message up. + let working = this.session.listenerCount('sms'); + this.held.hold(pduObjs); - this.emitted.set(sms, () => { this.held.release(pduObjs); }); + this.emitted.set(sms, () => { + working--; + + if (working <= 0) release(); + }); // A message nobody took is not work a shutdown can wait for. if (!this.session.emit('sms', sms)) this.held.release(pduObjs); diff --git a/src/sms.ts b/src/sms.ts index 750ea02..9f5d898 100644 --- a/src/sms.ts +++ b/src/sms.ts @@ -93,7 +93,7 @@ async function sendResp( sms: Sms, answered: { smsId: string }, options: SendRespOptions, - handlers: SmsHandlers, + handlers: Pick, ): Promise { const total = sms.pduObjs.length; @@ -118,10 +118,11 @@ async function sendResp( { message_id: segmentId(answered.smsId, index, total) }, ))); - // Only here: a refusal above put nothing on the wire, so the peer is still owed its response. - handlers.onAnswered(); + const failure = results.find(result => result.err); - return results.find(result => result.err) ?? {}; + if (!failure) handlers.onAnswered(); + + return failure ?? {}; } /** The receipt as text, which is all of it a peer below SMPP 3.4 is allowed to be sent. */ diff --git a/test/session-extras.test.ts b/test/session-extras.test.ts index f6b9212..c3a4925 100644 --- a/test/session-extras.test.ts +++ b/test/session-extras.test.ts @@ -1012,6 +1012,33 @@ describe('held message bounds', () => { }); }); +describe('sendResp()', () => { + // A response the wire never carried leaves the peer owed one, so nothing may count it answered. + test('does not count a response that never reached the wire as an answer', async t => { + const sock = new net.Socket(); + const session = new Session({ sock }); + + closeAfter(t, session); + sock.destroy(); + + let answered = 0; + const sms = createSms({ + from: '46701113311', + message: 'never answered', + pduObjs: [submitPdu(1)], + session, + to: '46709771337', + }, { + lostLink: () => false, + onAnswered: () => { answered++; }, + send: () => Promise.resolve({ err: new Error('never sent') }), + }); + + assert.match((await sms.sendResp()).err?.message ?? '', /Socket is closed/); + assert.equal(answered, 0); + }); +}); + describe('sendDlr()', () => { // A receipt cannot be resent wholesale without duplicating the segments that landed. test('names the segments the peer took, refused, and may have taken', async t => { @@ -1429,6 +1456,34 @@ describe('graceful shutdown', () => { assert.ok((await sent).err instanceof Error); }); + // A rejection leaves the other listeners running, unlike a throw, which stops emit() where it is. + test('waits for the listener still working when another one rejected', async t => { + const smpp = await startServer(t, { shutdownTimeout: 30_000 }); + const failed = once(resolve => { + smpp.on('session', bound => { + bound.on('sessionError', resolve); + bound.on('sms', async sms => { + await delay(100); + await sms.sendResp({ smsId: 'answered-after-the-other-gave-up' }); + }); + bound.on('sms', () => Promise.reject(new Error('the audit listener gave up'))); + }); + }); + const { session } = await connect(t, smpp); + + assert.ok(session); + + const sent = session.sendSms({ + from: '46701113311', + message: 'two listeners, one gives up', + to: '46709771337', + }); + + assert.equal((await failed).message, 'the audit listener gave up'); + assert.deepEqual(await peerOf(smpp).close(), {}); + assert.deepEqual((await sent).smsIds, ['answered-after-the-other-gave-up']); + }); + // Nothing reached the peer, so a drain counting this answered would report an outcome that never was. test('leaves a message the library refused to answer unanswered', async t => { const { sms, smpp } = await submitInFlight(t, {}, { shutdownTimeout: 50 }); From 0d30864886ea3cff4fce9bd7c9b9a09fab263960 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikael=20=27Lilleman=27=20G=C3=B6ransson?= Date: Wed, 2 Sep 2026 12:36:06 +0200 Subject: [PATCH 3/4] Say in goal 7 that the scope floor covers the seam too, and decline merge-state persistence --- AGENTS.md | 6 ++++-- todo.md | 11 +++++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a4210b2..9265dcd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,8 +34,10 @@ one wins. They do not override the hard rules below. promise this library can verify. The low-level surface is a passthrough: policy binds what the library composes, never what the caller wrote. 7. **Nothing that needs state wider than one session.** No throughput throttling, no persistence - across a restart, no coordination between processes. This is the scope floor, and it is why an - otherwise reasonable feature is declined without a fresh argument each time. + across a restart, no coordination between processes — and no seam handing the application state to + persist for one of those either, which commits to the same scope through the back door and + publishes an internal shape to do it. This is the scope floor, and it is why an otherwise + reasonable feature is declined without a fresh argument each time. 8. **It builds, tests and runs the same everywhere.** Container-only toolchain, no runtime dependencies, the Node 18 floor verified in CI rather than asserted, every README example executed by the suite. diff --git a/todo.md b/todo.md index b583cce..9860410 100644 --- a/todo.md +++ b/todo.md @@ -118,10 +118,6 @@ session message is a change to every call site. ## Worth doing, not blocking -- [ ] **Merge state does not survive a process restart.** A drop no longer discards it, but a restart - loses every incomplete group, and a peer has no reason to resend a receipt it already had - answered. Surviving one means exposing the merge state for the application to persist and hand - back, which is a public-surface decision. - [ ] **Group the session's collaborators under `src/session/`.** `session.ts` imports `dlr-merger`, `incoming-requests`, `link-timers`, `outgoing-requests`, `pdu-transport`, `reconnect-loop` and `send-sms`, and nothing else does, so the directory would make that @@ -168,6 +164,13 @@ session message is a change to every call site. ## Declined +- **Merge state surviving a process restart.** Declined by AGENTS.md goal 7, maintainer's call, + 2026-09-02. A restart loses every incomplete receipt group and a peer has no reason to resend one it + already had answered, so the loss is real — but surviving it means handing the application the merge + state to persist, which the scope floor covers as squarely as holding the state here would, and + which publishes the shape of `DlrMerger`'s groups against goal 6. Nothing is foreclosed: the seam + can still be added after 1.0.0 as a minor. + - **Throughput throttling — a TPS cap, and backing off on `ESME_RTHROTTLED`.** Declined by AGENTS.md goal 7: an operator's rate limit is scoped to the account, while the widest thing this library owns is a session, so a bucket here cannot see a second process binding the same account and is wrong in From 94184a9c63c456d4c0a0459e8ae8e535c70e8c2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikael=20=27Lilleman=27=20G=C3=B6ransson?= Date: Wed, 2 Sep 2026 12:40:51 +0200 Subject: [PATCH 4/4] Re-wrap the shutdown paragraph and leave the throw-versus-rejection note in one place --- README.md | 14 +++++++------- test/session-extras.test.ts | 1 - 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 9d879ae..0c13ad8 100644 --- a/README.md +++ b/README.md @@ -332,13 +332,13 @@ TypeScript users can import `SmppLog` to have the compiler check one. `sendSms()`, `send()`, `sendReturn()`, `unbind()` and `close()`. Both `close()` and `unbind()` refuse further sends, wait out the requests this end already sent for up to `shutdownTimeout`, and then tear down whatever is left, resolving to an `err` that says what was lost. They also wait for -every `sms` still in the application's hands, so a peer whose `submit_sm` is -being handled is answered rather than left to re-send it. That wait ends when `sendResp()` puts the -response on the wire, or when every listener that took the message has failed; answering its PDUs -through `sendReturn()` instead leaves the wait running until it gives up. `sendDlr()` is the one send the refusal lets -past, and it catches the wait when issued straight after `sendResp()`; await anything in between and -it races the shutdown like any other send. `close({ signal })` takes an -`AbortSignal` that cuts the wait short; `unbind()` takes none, and waits a further +every `sms` still in the application's hands, so a peer whose `submit_sm` is being handled is +answered rather than left to re-send it. That wait ends when `sendResp()` puts the response on the +wire, or when every listener that took the message has failed; answering its PDUs through +`sendReturn()` instead leaves the wait running until it gives up. `sendDlr()` is the one send the +refusal lets past, and it catches the wait when issued straight after `sendResp()`; await anything in +between and it races the shutdown like any other send. `close({ signal })` takes an `AbortSignal` +that cuts the wait short; `unbind()` takes none, and waits a further `responseTimeout` for its own response. `send()` reaches any of the 33 SMPP commands the codec knows, not just the four the session handles natively: diff --git a/test/session-extras.test.ts b/test/session-extras.test.ts index c3a4925..e3e259f 100644 --- a/test/session-extras.test.ts +++ b/test/session-extras.test.ts @@ -1456,7 +1456,6 @@ describe('graceful shutdown', () => { assert.ok((await sent).err instanceof Error); }); - // A rejection leaves the other listeners running, unlike a throw, which stops emit() where it is. test('waits for the listener still working when another one rejected', async t => { const smpp = await startServer(t, { shutdownTimeout: 30_000 }); const failed = once(resolve => {