mqtt-plus 1.4.13 → 1.4.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,11 @@
2
2
  ChangeLog
3
3
  =========
4
4
 
5
+ 1.4.14 (2026-03-11)
6
+ -------------------
7
+
8
+ - CLEANUP: align resource handling in source trait with sink trait
9
+
5
10
  1.4.13 (2026-03-10)
6
11
  -------------------
7
12
 
@@ -115,6 +115,8 @@ export class SinkTrait extends SourceTrait {
115
115
  /* utility functions for timeout management */
116
116
  const pushTimerId = `sink-push-recv:${requestId}`;
117
117
  const refreshPushTimeout = () => this.timerRefresh(pushTimerId, () => {
118
+ if (streamEnded)
119
+ return;
118
120
  const stream = this.pushStreams.get(requestId);
119
121
  if (stream !== undefined)
120
122
  stream.destroy(new Error("push stream timeout"));
@@ -150,6 +152,7 @@ export class SinkTrait extends SourceTrait {
150
152
  return;
151
153
  if (chunkParsed.error !== undefined) {
152
154
  streamEnded = true;
155
+ clearPushTimeout();
153
156
  readable.destroy(new Error(chunkParsed.error));
154
157
  }
155
158
  else {
@@ -161,6 +164,7 @@ export class SinkTrait extends SourceTrait {
161
164
  }
162
165
  if (chunkParsed.final) {
163
166
  streamEnded = true;
167
+ clearPushTimeout();
164
168
  readable.push(null);
165
169
  }
166
170
  }
@@ -286,6 +290,7 @@ export class SinkTrait extends SourceTrait {
286
290
  let remoteError = false;
287
291
  let pushAcked = false;
288
292
  let pushFinalized = false;
293
+ let pushDataFinalSent = false;
289
294
  let pushFinalizeResolve;
290
295
  let pushFinalizeReject;
291
296
  const pushFinalize = new Promise((resolve, reject) => {
@@ -355,6 +360,8 @@ export class SinkTrait extends SourceTrait {
355
360
  const chunkMsg = this.msg.makeSinkPushChunk(requestId, name, chunk, error, final, this.options.id, receiver);
356
361
  const message = this.codec.encode(chunkMsg);
357
362
  await this.publishToTopic(chunkTopic, message, { qos: 2, ...options });
363
+ if (error === undefined && final)
364
+ pushDataFinalSent = true;
358
365
  };
359
366
  /* iterate over all chunks of the buffer */
360
367
  if (data instanceof Readable)
@@ -378,8 +385,9 @@ export class SinkTrait extends SourceTrait {
378
385
  const error = ensureError(err);
379
386
  abortController.abort(error);
380
387
  /* send error chunk only if push was acked and error did not originate from receiver
381
- (before ack, the sink has no chunk handler yet and will time out on its own) */
382
- if (pushAcked && !remoteError) {
388
+ (before ack, the sink has no chunk handler yet and will time out on its own;
389
+ after final data chunk, no additional terminal chunk should be sent) */
390
+ if (pushAcked && !remoteError && !pushDataFinalSent) {
383
391
  const chunkTopic = this.options.topicMake(name, "sink-push-request", receiver);
384
392
  const chunkMsg = this.msg.makeSinkPushChunk(requestId, name, undefined, error.message, true, this.options.id, receiver);
385
393
  const message = this.codec.encode(chunkMsg);
@@ -98,6 +98,12 @@ export class SourceTrait extends ServiceTrait {
98
98
  const message = this.codec.encode(response);
99
99
  await this.publishToTopic(responseTopic, message, { qos: options.qos ?? 2 });
100
100
  };
101
+ /* create a resource spool for request cleanup */
102
+ const reqSpool = new Spool();
103
+ reqSpool.roll(() => {
104
+ this.onResponse.delete(`source-fetch-credit:${requestId}`);
105
+ this.sourceControllers.delete(requestId);
106
+ });
101
107
  /* define abort controller and signal */
102
108
  const abortController = new AbortController();
103
109
  this.sourceControllers.set(requestId, abortController);
@@ -117,11 +123,11 @@ export class SourceTrait extends ServiceTrait {
117
123
  gate.abort();
118
124
  this.sourceCreditGates.delete(requestId);
119
125
  }
120
- this.sourceControllers.delete(requestId);
121
- this.onResponse.delete(`source-fetch-credit:${requestId}`);
126
+ reqSpool.unroll();
122
127
  });
123
128
  const clearSourceTimeout = () => this.timerClear(sourceTimerId);
124
129
  refreshSourceTimeout();
130
+ reqSpool.roll(() => { clearSourceTimeout(); });
125
131
  /* callback for creating and sending a chunk message */
126
132
  const sendChunk = async (chunk, error, final) => {
127
133
  refreshSourceTimeout();
@@ -132,6 +138,7 @@ export class SourceTrait extends ServiceTrait {
132
138
  /* call the handler callback */
133
139
  let ackSent = false;
134
140
  let creditGate;
141
+ let cancelledByFetcher = false;
135
142
  try {
136
143
  if (topicName !== request.name)
137
144
  throw new Error(`source name mismatch (topic: "${topicName}", payload: "${request.name}")`);
@@ -143,12 +150,18 @@ export class SourceTrait extends ServiceTrait {
143
150
  const initialCredit = request.credit;
144
151
  creditGate = (initialCredit !== undefined && initialCredit > 0)
145
152
  ? new CreditGate(initialCredit) : undefined;
146
- if (creditGate)
153
+ if (creditGate) {
147
154
  this.sourceCreditGates.set(requestId, creditGate);
155
+ reqSpool.roll(() => {
156
+ creditGate.abort();
157
+ this.sourceCreditGates.delete(requestId);
158
+ });
159
+ }
148
160
  /* register credit/cancel handler (unconditional for cancel support) */
149
161
  this.onResponse.set(`source-fetch-credit:${requestId}`, (creditParsed) => {
150
162
  if (creditParsed.credit === 0) {
151
163
  /* cancel signal from fetcher */
164
+ cancelledByFetcher = true;
152
165
  abortController.abort(new Error(`source fetch "${name}" cancelled by fetcher`));
153
166
  return;
154
167
  }
@@ -178,22 +191,19 @@ export class SourceTrait extends ServiceTrait {
178
191
  /* cleanup stream resource (if provided by handler) */
179
192
  const error = ensureError(err, `handler for source "${name}" failed`);
180
193
  abortController.abort(error);
181
- /* send error as nak response or as error chunk */
182
- this.error(error);
183
- if (ackSent)
184
- await sendChunk(undefined, error.message, true).catch(() => { });
185
- else
186
- await sendResponse(error.message).catch(() => { });
194
+ /* on explicit fetcher cancellation, abort silently without emitting error responses */
195
+ if (!cancelledByFetcher) {
196
+ /* send error as nak response or as error chunk */
197
+ this.error(error);
198
+ if (ackSent)
199
+ await sendChunk(undefined, error.message, true).catch(() => { });
200
+ else
201
+ await sendResponse(error.message).catch(() => { });
202
+ }
187
203
  }
188
204
  finally {
189
205
  /* cleanup resources */
190
- clearSourceTimeout();
191
- if (creditGate) {
192
- creditGate.abort();
193
- this.sourceCreditGates.delete(requestId);
194
- }
195
- this.sourceControllers.delete(requestId);
196
- this.onResponse.delete(`source-fetch-credit:${requestId}`);
206
+ await reqSpool.unroll();
197
207
  }
198
208
  });
199
209
  spool.roll(() => { this.onRequest.delete(`source-fetch-request:${name}`); });
@@ -1626,6 +1626,11 @@ class SourceTrait extends ServiceTrait {
1626
1626
  const message = this.codec.encode(response);
1627
1627
  await this.publishToTopic(responseTopic, message, { qos: options.qos ?? 2 });
1628
1628
  };
1629
+ const reqSpool = new Spool();
1630
+ reqSpool.roll(() => {
1631
+ this.onResponse.delete(`source-fetch-credit:${requestId}`);
1632
+ this.sourceControllers.delete(requestId);
1633
+ });
1629
1634
  const abortController = new AbortController();
1630
1635
  this.sourceControllers.set(requestId, abortController);
1631
1636
  const abortSignal = abortController.signal;
@@ -1642,11 +1647,13 @@ class SourceTrait extends ServiceTrait {
1642
1647
  gate.abort();
1643
1648
  this.sourceCreditGates.delete(requestId);
1644
1649
  }
1645
- this.sourceControllers.delete(requestId);
1646
- this.onResponse.delete(`source-fetch-credit:${requestId}`);
1650
+ reqSpool.unroll();
1647
1651
  });
1648
1652
  const clearSourceTimeout = () => this.timerClear(sourceTimerId);
1649
1653
  refreshSourceTimeout();
1654
+ reqSpool.roll(() => {
1655
+ clearSourceTimeout();
1656
+ });
1650
1657
  const sendChunk = async (chunk, error, final) => {
1651
1658
  refreshSourceTimeout();
1652
1659
  const chunkMsg = this.msg.makeSourceFetchChunk(requestId, name, chunk, error, final, this.options.id, sender);
@@ -1655,6 +1662,7 @@ class SourceTrait extends ServiceTrait {
1655
1662
  };
1656
1663
  let ackSent = false;
1657
1664
  let creditGate;
1665
+ let cancelledByFetcher = false;
1658
1666
  try {
1659
1667
  if (topicName !== request.name)
1660
1668
  throw new Error(`source name mismatch (topic: "${topicName}", payload: "${request.name}")`);
@@ -1664,10 +1672,16 @@ class SourceTrait extends ServiceTrait {
1664
1672
  throw new Error(`source "${name}" failed authentication`);
1665
1673
  const initialCredit = request.credit;
1666
1674
  creditGate = initialCredit !== void 0 && initialCredit > 0 ? new CreditGate(initialCredit) : void 0;
1667
- if (creditGate)
1675
+ if (creditGate) {
1668
1676
  this.sourceCreditGates.set(requestId, creditGate);
1677
+ reqSpool.roll(() => {
1678
+ creditGate.abort();
1679
+ this.sourceCreditGates.delete(requestId);
1680
+ });
1681
+ }
1669
1682
  this.onResponse.set(`source-fetch-credit:${requestId}`, (creditParsed) => {
1670
1683
  if (creditParsed.credit === 0) {
1684
+ cancelledByFetcher = true;
1671
1685
  abortController.abort(new Error(`source fetch "${name}" cancelled by fetcher`));
1672
1686
  return;
1673
1687
  }
@@ -1690,21 +1704,17 @@ class SourceTrait extends ServiceTrait {
1690
1704
  } catch (err) {
1691
1705
  const error = ensureError(err, `handler for source "${name}" failed`);
1692
1706
  abortController.abort(error);
1693
- this.error(error);
1694
- if (ackSent)
1695
- await sendChunk(void 0, error.message, true).catch(() => {
1696
- });
1697
- else
1698
- await sendResponse(error.message).catch(() => {
1699
- });
1700
- } finally {
1701
- clearSourceTimeout();
1702
- if (creditGate) {
1703
- creditGate.abort();
1704
- this.sourceCreditGates.delete(requestId);
1707
+ if (!cancelledByFetcher) {
1708
+ this.error(error);
1709
+ if (ackSent)
1710
+ await sendChunk(void 0, error.message, true).catch(() => {
1711
+ });
1712
+ else
1713
+ await sendResponse(error.message).catch(() => {
1714
+ });
1705
1715
  }
1706
- this.sourceControllers.delete(requestId);
1707
- this.onResponse.delete(`source-fetch-credit:${requestId}`);
1716
+ } finally {
1717
+ await reqSpool.unroll();
1708
1718
  }
1709
1719
  });
1710
1720
  spool.roll(() => {
@@ -1929,6 +1939,8 @@ class SinkTrait extends SourceTrait {
1929
1939
  } : void 0;
1930
1940
  const pushTimerId = `sink-push-recv:${requestId}`;
1931
1941
  const refreshPushTimeout = () => this.timerRefresh(pushTimerId, () => {
1942
+ if (streamEnded)
1943
+ return;
1932
1944
  const stream = this.pushStreams.get(requestId);
1933
1945
  if (stream !== void 0)
1934
1946
  stream.destroy(new Error("push stream timeout"));
@@ -1965,6 +1977,7 @@ class SinkTrait extends SourceTrait {
1965
1977
  return;
1966
1978
  if (chunkParsed.error !== void 0) {
1967
1979
  streamEnded = true;
1980
+ clearPushTimeout();
1968
1981
  readable.destroy(new Error(chunkParsed.error));
1969
1982
  } else {
1970
1983
  refreshPushTimeout();
@@ -1975,6 +1988,7 @@ class SinkTrait extends SourceTrait {
1975
1988
  }
1976
1989
  if (chunkParsed.final) {
1977
1990
  streamEnded = true;
1991
+ clearPushTimeout();
1978
1992
  readable.push(null);
1979
1993
  }
1980
1994
  }
@@ -2087,6 +2101,7 @@ class SinkTrait extends SourceTrait {
2087
2101
  let remoteError = false;
2088
2102
  let pushAcked = false;
2089
2103
  let pushFinalized = false;
2104
+ let pushDataFinalSent = false;
2090
2105
  let pushFinalizeResolve;
2091
2106
  let pushFinalizeReject;
2092
2107
  const pushFinalize = new Promise((resolve, reject) => {
@@ -2158,6 +2173,8 @@ class SinkTrait extends SourceTrait {
2158
2173
  const chunkMsg = this.msg.makeSinkPushChunk(requestId, name, chunk, error, final, this.options.id, receiver);
2159
2174
  const message = this.codec.encode(chunkMsg);
2160
2175
  await this.publishToTopic(chunkTopic, message, { qos: 2, ...options });
2176
+ if (error === void 0 && final)
2177
+ pushDataFinalSent = true;
2161
2178
  };
2162
2179
  if (data instanceof node_stream.Readable)
2163
2180
  await sendStreamAsChunks(data, this.options.chunkSize, sendChunk, creditGate, abortSignal);
@@ -2177,7 +2194,7 @@ class SinkTrait extends SourceTrait {
2177
2194
  } catch (err) {
2178
2195
  const error = ensureError(err);
2179
2196
  abortController.abort(error);
2180
- if (pushAcked && !remoteError) {
2197
+ if (pushAcked && !remoteError && !pushDataFinalSent) {
2181
2198
  const chunkTopic = this.options.topicMake(name, "sink-push-request", receiver);
2182
2199
  const chunkMsg = this.msg.makeSinkPushChunk(requestId, name, void 0, error.message, true, this.options.id, receiver);
2183
2200
  const message = this.codec.encode(chunkMsg);
@@ -1605,6 +1605,11 @@ class SourceTrait extends ServiceTrait {
1605
1605
  const message = this.codec.encode(response);
1606
1606
  await this.publishToTopic(responseTopic, message, { qos: options.qos ?? 2 });
1607
1607
  };
1608
+ const reqSpool = new Spool();
1609
+ reqSpool.roll(() => {
1610
+ this.onResponse.delete(`source-fetch-credit:${requestId}`);
1611
+ this.sourceControllers.delete(requestId);
1612
+ });
1608
1613
  const abortController = new AbortController();
1609
1614
  this.sourceControllers.set(requestId, abortController);
1610
1615
  const abortSignal = abortController.signal;
@@ -1621,11 +1626,13 @@ class SourceTrait extends ServiceTrait {
1621
1626
  gate.abort();
1622
1627
  this.sourceCreditGates.delete(requestId);
1623
1628
  }
1624
- this.sourceControllers.delete(requestId);
1625
- this.onResponse.delete(`source-fetch-credit:${requestId}`);
1629
+ reqSpool.unroll();
1626
1630
  });
1627
1631
  const clearSourceTimeout = () => this.timerClear(sourceTimerId);
1628
1632
  refreshSourceTimeout();
1633
+ reqSpool.roll(() => {
1634
+ clearSourceTimeout();
1635
+ });
1629
1636
  const sendChunk = async (chunk, error, final) => {
1630
1637
  refreshSourceTimeout();
1631
1638
  const chunkMsg = this.msg.makeSourceFetchChunk(requestId, name, chunk, error, final, this.options.id, sender);
@@ -1634,6 +1641,7 @@ class SourceTrait extends ServiceTrait {
1634
1641
  };
1635
1642
  let ackSent = false;
1636
1643
  let creditGate;
1644
+ let cancelledByFetcher = false;
1637
1645
  try {
1638
1646
  if (topicName !== request.name)
1639
1647
  throw new Error(`source name mismatch (topic: "${topicName}", payload: "${request.name}")`);
@@ -1643,10 +1651,16 @@ class SourceTrait extends ServiceTrait {
1643
1651
  throw new Error(`source "${name}" failed authentication`);
1644
1652
  const initialCredit = request.credit;
1645
1653
  creditGate = initialCredit !== void 0 && initialCredit > 0 ? new CreditGate(initialCredit) : void 0;
1646
- if (creditGate)
1654
+ if (creditGate) {
1647
1655
  this.sourceCreditGates.set(requestId, creditGate);
1656
+ reqSpool.roll(() => {
1657
+ creditGate.abort();
1658
+ this.sourceCreditGates.delete(requestId);
1659
+ });
1660
+ }
1648
1661
  this.onResponse.set(`source-fetch-credit:${requestId}`, (creditParsed) => {
1649
1662
  if (creditParsed.credit === 0) {
1663
+ cancelledByFetcher = true;
1650
1664
  abortController.abort(new Error(`source fetch "${name}" cancelled by fetcher`));
1651
1665
  return;
1652
1666
  }
@@ -1669,21 +1683,17 @@ class SourceTrait extends ServiceTrait {
1669
1683
  } catch (err) {
1670
1684
  const error = ensureError(err, `handler for source "${name}" failed`);
1671
1685
  abortController.abort(error);
1672
- this.error(error);
1673
- if (ackSent)
1674
- await sendChunk(void 0, error.message, true).catch(() => {
1675
- });
1676
- else
1677
- await sendResponse(error.message).catch(() => {
1678
- });
1679
- } finally {
1680
- clearSourceTimeout();
1681
- if (creditGate) {
1682
- creditGate.abort();
1683
- this.sourceCreditGates.delete(requestId);
1686
+ if (!cancelledByFetcher) {
1687
+ this.error(error);
1688
+ if (ackSent)
1689
+ await sendChunk(void 0, error.message, true).catch(() => {
1690
+ });
1691
+ else
1692
+ await sendResponse(error.message).catch(() => {
1693
+ });
1684
1694
  }
1685
- this.sourceControllers.delete(requestId);
1686
- this.onResponse.delete(`source-fetch-credit:${requestId}`);
1695
+ } finally {
1696
+ await reqSpool.unroll();
1687
1697
  }
1688
1698
  });
1689
1699
  spool.roll(() => {
@@ -1908,6 +1918,8 @@ class SinkTrait extends SourceTrait {
1908
1918
  } : void 0;
1909
1919
  const pushTimerId = `sink-push-recv:${requestId}`;
1910
1920
  const refreshPushTimeout = () => this.timerRefresh(pushTimerId, () => {
1921
+ if (streamEnded)
1922
+ return;
1911
1923
  const stream = this.pushStreams.get(requestId);
1912
1924
  if (stream !== void 0)
1913
1925
  stream.destroy(new Error("push stream timeout"));
@@ -1944,6 +1956,7 @@ class SinkTrait extends SourceTrait {
1944
1956
  return;
1945
1957
  if (chunkParsed.error !== void 0) {
1946
1958
  streamEnded = true;
1959
+ clearPushTimeout();
1947
1960
  readable.destroy(new Error(chunkParsed.error));
1948
1961
  } else {
1949
1962
  refreshPushTimeout();
@@ -1954,6 +1967,7 @@ class SinkTrait extends SourceTrait {
1954
1967
  }
1955
1968
  if (chunkParsed.final) {
1956
1969
  streamEnded = true;
1970
+ clearPushTimeout();
1957
1971
  readable.push(null);
1958
1972
  }
1959
1973
  }
@@ -2066,6 +2080,7 @@ class SinkTrait extends SourceTrait {
2066
2080
  let remoteError = false;
2067
2081
  let pushAcked = false;
2068
2082
  let pushFinalized = false;
2083
+ let pushDataFinalSent = false;
2069
2084
  let pushFinalizeResolve;
2070
2085
  let pushFinalizeReject;
2071
2086
  const pushFinalize = new Promise((resolve, reject) => {
@@ -2137,6 +2152,8 @@ class SinkTrait extends SourceTrait {
2137
2152
  const chunkMsg = this.msg.makeSinkPushChunk(requestId, name, chunk, error, final, this.options.id, receiver);
2138
2153
  const message = this.codec.encode(chunkMsg);
2139
2154
  await this.publishToTopic(chunkTopic, message, { qos: 2, ...options });
2155
+ if (error === void 0 && final)
2156
+ pushDataFinalSent = true;
2140
2157
  };
2141
2158
  if (data instanceof Readable)
2142
2159
  await sendStreamAsChunks(data, this.options.chunkSize, sendChunk, creditGate, abortSignal);
@@ -2156,7 +2173,7 @@ class SinkTrait extends SourceTrait {
2156
2173
  } catch (err) {
2157
2174
  const error = ensureError(err);
2158
2175
  abortController.abort(error);
2159
- if (pushAcked && !remoteError) {
2176
+ if (pushAcked && !remoteError && !pushDataFinalSent) {
2160
2177
  const chunkTopic = this.options.topicMake(name, "sink-push-request", receiver);
2161
2178
  const chunkMsg = this.msg.makeSinkPushChunk(requestId, name, void 0, error.message, true, this.options.id, receiver);
2162
2179
  const message = this.codec.encode(chunkMsg);