imapflow 1.2.10 → 1.2.12
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/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +14 -0
- package/lib/imap-flow.js +36 -16
- package/lib/tools.js +1 -6
- package/package.json +5 -5
- package/test/bodystructure-test.js +0 -231
- package/test/fetch-generator-test.js +216 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.2.12](https://github.com/postalsys/imapflow/compare/v1.2.11...v1.2.12) (2026-03-05)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* prevent unhandled NoConnection rejection in IDLE recovery ([e177372](https://github.com/postalsys/imapflow/commit/e17737296b6b2de9ba25392b79542cf6d7d45826))
|
|
9
|
+
|
|
10
|
+
## [1.2.11](https://github.com/postalsys/imapflow/compare/v1.2.10...v1.2.11) (2026-03-03)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* release backpressure in fetch() generator on early break/throw ([d42a377](https://github.com/postalsys/imapflow/commit/d42a377f8102f44fc76d8fa9a268bbf2526a7fa8)), closes [#338](https://github.com/postalsys/imapflow/issues/338)
|
|
16
|
+
|
|
3
17
|
## [1.2.10](https://github.com/postalsys/imapflow/compare/v1.2.9...v1.2.10) (2026-02-19)
|
|
4
18
|
|
|
5
19
|
|
package/lib/imap-flow.js
CHANGED
|
@@ -856,7 +856,12 @@ class ImapFlow extends EventEmitter {
|
|
|
856
856
|
// Attempt to recover IDLE connections
|
|
857
857
|
this.run('NOOP')
|
|
858
858
|
.then(() => this.idle())
|
|
859
|
-
.catch(
|
|
859
|
+
.catch(err => {
|
|
860
|
+
this.log.warn({ msg: 'IDLE recovery failed after timeout', err, cid: this.id });
|
|
861
|
+
if (!this.isClosed) {
|
|
862
|
+
this.close();
|
|
863
|
+
}
|
|
864
|
+
});
|
|
860
865
|
} else {
|
|
861
866
|
// Close immediately for non-IDLE operations
|
|
862
867
|
this.log.debug({ msg: 'Socket timeout', cid: this.id });
|
|
@@ -2718,6 +2723,7 @@ class ImapFlow extends EventEmitter {
|
|
|
2718
2723
|
// two: when the consumer is waiting and the queue is empty, `push` is set to
|
|
2719
2724
|
// a function that wakes up the consumer when new data arrives.
|
|
2720
2725
|
let finished = false;
|
|
2726
|
+
let aborted = false;
|
|
2721
2727
|
let push = false;
|
|
2722
2728
|
let rowQueue = [];
|
|
2723
2729
|
|
|
@@ -2755,6 +2761,10 @@ class ImapFlow extends EventEmitter {
|
|
|
2755
2761
|
binary: options.binary,
|
|
2756
2762
|
changedSince: options.changedSince,
|
|
2757
2763
|
onUntaggedFetch: (untagged, next) => {
|
|
2764
|
+
if (aborted) {
|
|
2765
|
+
next();
|
|
2766
|
+
return;
|
|
2767
|
+
}
|
|
2758
2768
|
rowQueue.push({
|
|
2759
2769
|
value: {
|
|
2760
2770
|
response: untagged,
|
|
@@ -2779,26 +2789,36 @@ class ImapFlow extends EventEmitter {
|
|
|
2779
2789
|
}
|
|
2780
2790
|
});
|
|
2781
2791
|
|
|
2782
|
-
let
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2792
|
+
let lastRes = null;
|
|
2793
|
+
try {
|
|
2794
|
+
let res;
|
|
2795
|
+
while ((res = await getNext())) {
|
|
2796
|
+
lastRes = res;
|
|
2797
|
+
|
|
2798
|
+
if (this.isClosed || !this.socket || this.socket.destroyed) {
|
|
2799
|
+
let error = new Error('Connection closed');
|
|
2800
|
+
error.code = 'EConnectionClosed';
|
|
2801
|
+
throw error;
|
|
2802
|
+
}
|
|
2789
2803
|
|
|
2790
|
-
if (res !== null) {
|
|
2791
2804
|
yield res.response;
|
|
2792
2805
|
// Signal the FETCH handler to process the next untagged response
|
|
2793
2806
|
res.next();
|
|
2807
|
+
lastRes = null;
|
|
2808
|
+
}
|
|
2809
|
+
} finally {
|
|
2810
|
+
aborted = true;
|
|
2811
|
+
// Release backpressure for the item that was yielded but whose
|
|
2812
|
+
// next() was not yet called (happens on break/return/throw)
|
|
2813
|
+
if (lastRes && typeof lastRes.next === 'function') {
|
|
2814
|
+
lastRes.next();
|
|
2815
|
+
}
|
|
2816
|
+
while (rowQueue.length) {
|
|
2817
|
+
let entry = rowQueue.shift();
|
|
2818
|
+
if (entry.value && typeof entry.value.next === 'function') {
|
|
2819
|
+
entry.value.next();
|
|
2820
|
+
}
|
|
2794
2821
|
}
|
|
2795
|
-
}
|
|
2796
|
-
|
|
2797
|
-
if (!finished) {
|
|
2798
|
-
// FETCH never finished!
|
|
2799
|
-
let error = new Error('FETCH did not finish');
|
|
2800
|
-
error.code = 'ENotFinished';
|
|
2801
|
-
throw error;
|
|
2802
2822
|
}
|
|
2803
2823
|
}
|
|
2804
2824
|
|
package/lib/tools.js
CHANGED
|
@@ -206,11 +206,7 @@ const tools = {
|
|
|
206
206
|
if (cur) {
|
|
207
207
|
node = cur;
|
|
208
208
|
} else {
|
|
209
|
-
// not yet set
|
|
210
|
-
cur = {
|
|
211
|
-
name: parent,
|
|
212
|
-
folders: []
|
|
213
|
-
};
|
|
209
|
+
// not yet set, will be created by the caller
|
|
214
210
|
}
|
|
215
211
|
}
|
|
216
212
|
|
|
@@ -901,7 +897,6 @@ const tools = {
|
|
|
901
897
|
if (node[i]) {
|
|
902
898
|
curNode.location = ((node[i] || {}).value || '').toString();
|
|
903
899
|
}
|
|
904
|
-
i++;
|
|
905
900
|
}
|
|
906
901
|
|
|
907
902
|
return curNode;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "imapflow",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.12",
|
|
4
4
|
"description": "IMAP Client for Node",
|
|
5
5
|
"main": "lib/imap-flow.js",
|
|
6
6
|
"types": "lib/imap-flow.d.ts",
|
|
@@ -27,10 +27,10 @@
|
|
|
27
27
|
},
|
|
28
28
|
"homepage": "https://imapflow.com/",
|
|
29
29
|
"devDependencies": {
|
|
30
|
-
"@eslint/js": "
|
|
31
|
-
"@types/node": "25.3.
|
|
32
|
-
"c8": "
|
|
33
|
-
"eslint": "
|
|
30
|
+
"@eslint/js": "10.0.1",
|
|
31
|
+
"@types/node": "25.3.3",
|
|
32
|
+
"c8": "11.0.0",
|
|
33
|
+
"eslint": "10.0.2",
|
|
34
34
|
"eslint-config-nodemailer": "1.2.0",
|
|
35
35
|
"eslint-config-prettier": "10.1.8",
|
|
36
36
|
"grunt": "1.6.1",
|
|
@@ -234,237 +234,6 @@ module.exports['Process correct TEXT with line count'] = test => {
|
|
|
234
234
|
null
|
|
235
235
|
];
|
|
236
236
|
|
|
237
|
-
attribute = [
|
|
238
|
-
[
|
|
239
|
-
[
|
|
240
|
-
{
|
|
241
|
-
type: 'STRING',
|
|
242
|
-
value: 'text'
|
|
243
|
-
},
|
|
244
|
-
{
|
|
245
|
-
type: 'STRING',
|
|
246
|
-
value: 'plain'
|
|
247
|
-
},
|
|
248
|
-
[
|
|
249
|
-
{
|
|
250
|
-
type: 'STRING',
|
|
251
|
-
value: 'charset'
|
|
252
|
-
},
|
|
253
|
-
{
|
|
254
|
-
type: 'STRING',
|
|
255
|
-
value: 'UTF-8'
|
|
256
|
-
}
|
|
257
|
-
],
|
|
258
|
-
null,
|
|
259
|
-
null,
|
|
260
|
-
{
|
|
261
|
-
type: 'STRING',
|
|
262
|
-
value: '7bit'
|
|
263
|
-
},
|
|
264
|
-
{
|
|
265
|
-
type: 'ATOM',
|
|
266
|
-
value: '23'
|
|
267
|
-
},
|
|
268
|
-
{
|
|
269
|
-
type: 'ATOM',
|
|
270
|
-
value: '1'
|
|
271
|
-
},
|
|
272
|
-
null,
|
|
273
|
-
null,
|
|
274
|
-
null,
|
|
275
|
-
null
|
|
276
|
-
],
|
|
277
|
-
[
|
|
278
|
-
{
|
|
279
|
-
type: 'STRING',
|
|
280
|
-
value: 'text'
|
|
281
|
-
},
|
|
282
|
-
{
|
|
283
|
-
type: 'STRING',
|
|
284
|
-
value: 'html'
|
|
285
|
-
},
|
|
286
|
-
[
|
|
287
|
-
{
|
|
288
|
-
type: 'STRING',
|
|
289
|
-
value: 'charset'
|
|
290
|
-
},
|
|
291
|
-
{
|
|
292
|
-
type: 'STRING',
|
|
293
|
-
value: 'UTF-8'
|
|
294
|
-
}
|
|
295
|
-
],
|
|
296
|
-
null,
|
|
297
|
-
null,
|
|
298
|
-
{
|
|
299
|
-
type: 'STRING',
|
|
300
|
-
value: '7bit'
|
|
301
|
-
},
|
|
302
|
-
{
|
|
303
|
-
type: 'ATOM',
|
|
304
|
-
value: '49'
|
|
305
|
-
},
|
|
306
|
-
{
|
|
307
|
-
type: 'ATOM',
|
|
308
|
-
value: '1'
|
|
309
|
-
},
|
|
310
|
-
null,
|
|
311
|
-
null,
|
|
312
|
-
null,
|
|
313
|
-
null
|
|
314
|
-
],
|
|
315
|
-
{
|
|
316
|
-
type: 'STRING',
|
|
317
|
-
value: 'alternative'
|
|
318
|
-
},
|
|
319
|
-
[
|
|
320
|
-
{
|
|
321
|
-
type: 'STRING',
|
|
322
|
-
value: 'boundary'
|
|
323
|
-
},
|
|
324
|
-
{
|
|
325
|
-
type: 'STRING',
|
|
326
|
-
value: '000000000000b56402062b1fba83'
|
|
327
|
-
}
|
|
328
|
-
],
|
|
329
|
-
null,
|
|
330
|
-
null,
|
|
331
|
-
null
|
|
332
|
-
],
|
|
333
|
-
[
|
|
334
|
-
{
|
|
335
|
-
type: 'STRING',
|
|
336
|
-
value: 'text'
|
|
337
|
-
},
|
|
338
|
-
{
|
|
339
|
-
type: 'STRING',
|
|
340
|
-
value: 'plain'
|
|
341
|
-
},
|
|
342
|
-
[
|
|
343
|
-
{
|
|
344
|
-
type: 'STRING',
|
|
345
|
-
value: 'charset'
|
|
346
|
-
},
|
|
347
|
-
{
|
|
348
|
-
type: 'STRING',
|
|
349
|
-
value: 'US-ASCII'
|
|
350
|
-
},
|
|
351
|
-
{
|
|
352
|
-
type: 'STRING',
|
|
353
|
-
value: 'name'
|
|
354
|
-
},
|
|
355
|
-
{
|
|
356
|
-
type: 'STRING',
|
|
357
|
-
value: 'log_imap_missing_attachments.txt'
|
|
358
|
-
}
|
|
359
|
-
],
|
|
360
|
-
{
|
|
361
|
-
type: 'STRING',
|
|
362
|
-
value: '<f_m5mnf91y1>'
|
|
363
|
-
},
|
|
364
|
-
null,
|
|
365
|
-
{
|
|
366
|
-
type: 'STRING',
|
|
367
|
-
value: 'base64'
|
|
368
|
-
},
|
|
369
|
-
{
|
|
370
|
-
type: 'ATOM',
|
|
371
|
-
value: '33954'
|
|
372
|
-
},
|
|
373
|
-
{
|
|
374
|
-
type: 'ATOM',
|
|
375
|
-
value: '435'
|
|
376
|
-
},
|
|
377
|
-
null,
|
|
378
|
-
[
|
|
379
|
-
{
|
|
380
|
-
type: 'STRING',
|
|
381
|
-
value: 'attachment'
|
|
382
|
-
},
|
|
383
|
-
[
|
|
384
|
-
{
|
|
385
|
-
type: 'STRING',
|
|
386
|
-
value: 'filename'
|
|
387
|
-
},
|
|
388
|
-
{
|
|
389
|
-
type: 'STRING',
|
|
390
|
-
value: 'log_imap_missing_attachments.txt'
|
|
391
|
-
}
|
|
392
|
-
]
|
|
393
|
-
],
|
|
394
|
-
null,
|
|
395
|
-
null
|
|
396
|
-
],
|
|
397
|
-
[
|
|
398
|
-
{
|
|
399
|
-
type: 'STRING',
|
|
400
|
-
value: 'image'
|
|
401
|
-
},
|
|
402
|
-
{
|
|
403
|
-
type: 'STRING',
|
|
404
|
-
value: 'jpeg'
|
|
405
|
-
},
|
|
406
|
-
[
|
|
407
|
-
{
|
|
408
|
-
type: 'STRING',
|
|
409
|
-
value: 'name'
|
|
410
|
-
},
|
|
411
|
-
{
|
|
412
|
-
type: 'STRING',
|
|
413
|
-
value: 'img2.jpeg'
|
|
414
|
-
}
|
|
415
|
-
],
|
|
416
|
-
{
|
|
417
|
-
type: 'STRING',
|
|
418
|
-
value: '<f_m5mnf4c70>'
|
|
419
|
-
},
|
|
420
|
-
null,
|
|
421
|
-
{
|
|
422
|
-
type: 'STRING',
|
|
423
|
-
value: 'base64'
|
|
424
|
-
},
|
|
425
|
-
{
|
|
426
|
-
type: 'ATOM',
|
|
427
|
-
value: '3776960'
|
|
428
|
-
},
|
|
429
|
-
null,
|
|
430
|
-
[
|
|
431
|
-
{
|
|
432
|
-
type: 'STRING',
|
|
433
|
-
value: 'attachment'
|
|
434
|
-
},
|
|
435
|
-
[
|
|
436
|
-
{
|
|
437
|
-
type: 'STRING',
|
|
438
|
-
value: 'filename'
|
|
439
|
-
},
|
|
440
|
-
{
|
|
441
|
-
type: 'STRING',
|
|
442
|
-
value: 'img2.jpeg'
|
|
443
|
-
}
|
|
444
|
-
]
|
|
445
|
-
],
|
|
446
|
-
null,
|
|
447
|
-
null
|
|
448
|
-
],
|
|
449
|
-
{
|
|
450
|
-
type: 'STRING',
|
|
451
|
-
value: 'mixed'
|
|
452
|
-
},
|
|
453
|
-
[
|
|
454
|
-
{
|
|
455
|
-
type: 'STRING',
|
|
456
|
-
value: 'boundary'
|
|
457
|
-
},
|
|
458
|
-
{
|
|
459
|
-
type: 'STRING',
|
|
460
|
-
value: '000000000000b56402062b1fba85'
|
|
461
|
-
}
|
|
462
|
-
],
|
|
463
|
-
null,
|
|
464
|
-
null,
|
|
465
|
-
null
|
|
466
|
-
];
|
|
467
|
-
|
|
468
237
|
let bodyStruct = parseBodystructure(attribute);
|
|
469
238
|
|
|
470
239
|
test.deepEqual(bodyStruct, {
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { ImapFlow } = require('../lib/imap-flow');
|
|
4
|
+
|
|
5
|
+
// Helper: create a minimal mock context with the fetch() generator bound to it.
|
|
6
|
+
// The `run` override controls how untagged FETCH responses are delivered.
|
|
7
|
+
const createFetchContext = runOverride => ({
|
|
8
|
+
mailbox: { path: 'INBOX', exists: 10 },
|
|
9
|
+
isClosed: false,
|
|
10
|
+
socket: { destroyed: false },
|
|
11
|
+
resolveRange: async range => range,
|
|
12
|
+
run: runOverride
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// Helper: invoke the fetch generator on a mock context
|
|
16
|
+
function callFetch(ctx, range, query, options) {
|
|
17
|
+
return ImapFlow.prototype.fetch.call(ctx, range, query, options);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Helper: create a mock `run` that delivers `count` untagged FETCH responses,
|
|
21
|
+
// each with a `next` callback for backpressure. Returns a tracking object.
|
|
22
|
+
function createMockRun(count) {
|
|
23
|
+
const tracker = {
|
|
24
|
+
nextCalls: [], // records which message indices had next() called
|
|
25
|
+
onUntaggedFetch: null,
|
|
26
|
+
runResolved: false
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const run = async (_cmd, _range, _query, opts) => {
|
|
30
|
+
tracker.onUntaggedFetch = opts.onUntaggedFetch;
|
|
31
|
+
|
|
32
|
+
for (let i = 0; i < count; i++) {
|
|
33
|
+
await new Promise(resolve => {
|
|
34
|
+
opts.onUntaggedFetch({ seq: i + 1, uid: 100 + i }, () => {
|
|
35
|
+
tracker.nextCalls.push(i);
|
|
36
|
+
resolve();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
tracker.runResolved = true;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
return { tracker, run };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Test: early break releases backpressure
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
module.exports['fetch generator: early break releases backpressure'] = async test => {
|
|
50
|
+
const { tracker, run } = createMockRun(5);
|
|
51
|
+
const ctx = createFetchContext(run);
|
|
52
|
+
|
|
53
|
+
const gen = callFetch(ctx, '1:5', { uid: true });
|
|
54
|
+
// Consume only the first message and break
|
|
55
|
+
for await (let msg of gen) {
|
|
56
|
+
test.ok(msg.seq === 1, 'should receive the first message');
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Allow microtasks to flush (the finally block runs synchronously with
|
|
61
|
+
// generator return, but the remaining run() deliveries are async)
|
|
62
|
+
await new Promise(r => setTimeout(r, 50));
|
|
63
|
+
|
|
64
|
+
// The first message had next() called by the yield loop (res.next()).
|
|
65
|
+
// Remaining queued/future messages should have next() called by the
|
|
66
|
+
// finally block or the aborted guard.
|
|
67
|
+
test.equal(tracker.nextCalls.length, 5, 'all 5 messages should have next() called');
|
|
68
|
+
test.done();
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Test: subsequent operations work after early exit
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
module.exports['fetch generator: connection usable after early break'] = async test => {
|
|
75
|
+
let runCallCount = 0;
|
|
76
|
+
|
|
77
|
+
const run = async (_cmd, _range, _query, opts) => {
|
|
78
|
+
runCallCount++;
|
|
79
|
+
// Deliver 3 messages
|
|
80
|
+
for (let i = 0; i < 3; i++) {
|
|
81
|
+
await new Promise(resolve => {
|
|
82
|
+
opts.onUntaggedFetch({ seq: i + 1, uid: 200 + i }, () => resolve());
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const ctx = createFetchContext(run);
|
|
88
|
+
|
|
89
|
+
// First fetch: break early
|
|
90
|
+
const gen1 = callFetch(ctx, '1:3', { uid: true });
|
|
91
|
+
for await (let msg of gen1) {
|
|
92
|
+
test.ok(msg.seq === 1);
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
await new Promise(r => setTimeout(r, 50));
|
|
97
|
+
|
|
98
|
+
// Second fetch: should work (run gets called again)
|
|
99
|
+
const gen2 = callFetch(ctx, '1:3', { uid: true });
|
|
100
|
+
let secondFetchMessages = [];
|
|
101
|
+
for await (let msg of gen2) {
|
|
102
|
+
secondFetchMessages.push(msg);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
test.ok(runCallCount === 2, 'run was called for both fetch operations');
|
|
106
|
+
test.ok(secondFetchMessages.length === 3, 'second fetch received all 3 messages');
|
|
107
|
+
test.done();
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Test: normal full iteration works correctly
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
module.exports['fetch generator: normal full iteration receives all messages'] = async test => {
|
|
114
|
+
const { tracker, run } = createMockRun(4);
|
|
115
|
+
const ctx = createFetchContext(run);
|
|
116
|
+
|
|
117
|
+
let messages = [];
|
|
118
|
+
for await (let msg of callFetch(ctx, '1:4', { uid: true })) {
|
|
119
|
+
messages.push(msg);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
test.equal(messages.length, 4, 'should receive all 4 messages');
|
|
123
|
+
test.equal(messages[0].seq, 1);
|
|
124
|
+
test.equal(messages[3].seq, 4);
|
|
125
|
+
test.equal(tracker.nextCalls.length, 4, 'all next() callbacks were called');
|
|
126
|
+
test.done();
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Test: error thrown in loop body releases backpressure
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
module.exports['fetch generator: error in loop body releases backpressure'] = async test => {
|
|
133
|
+
const { tracker, run } = createMockRun(5);
|
|
134
|
+
const ctx = createFetchContext(run);
|
|
135
|
+
|
|
136
|
+
const gen = callFetch(ctx, '1:5', { uid: true });
|
|
137
|
+
|
|
138
|
+
let caught = false;
|
|
139
|
+
try {
|
|
140
|
+
for await (let msg of gen) {
|
|
141
|
+
if (msg.seq === 2) {
|
|
142
|
+
throw new Error('intentional test error');
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
} catch (err) {
|
|
146
|
+
caught = true;
|
|
147
|
+
test.equal(err.message, 'intentional test error');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
await new Promise(r => setTimeout(r, 50));
|
|
151
|
+
|
|
152
|
+
test.ok(caught, 'error was caught');
|
|
153
|
+
// Messages 0 and 1 had next() called by the yield loop. The rest should
|
|
154
|
+
// be drained by the finally block or the aborted guard.
|
|
155
|
+
test.equal(tracker.nextCalls.length, 5, 'all 5 messages should have next() called');
|
|
156
|
+
test.done();
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// Test: zero messages from server
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
module.exports['fetch generator: zero messages yields nothing'] = async test => {
|
|
163
|
+
const { tracker, run } = createMockRun(0);
|
|
164
|
+
const ctx = createFetchContext(run);
|
|
165
|
+
|
|
166
|
+
let messages = [];
|
|
167
|
+
for await (let msg of callFetch(ctx, '1:*', { uid: true })) {
|
|
168
|
+
messages.push(msg);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
test.equal(messages.length, 0, 'no messages should be yielded');
|
|
172
|
+
test.equal(tracker.nextCalls.length, 0, 'no next() calls needed');
|
|
173
|
+
test.ok(tracker.runResolved, 'run should have resolved');
|
|
174
|
+
test.done();
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// Test: connection closed during iteration throws and releases backpressure
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
module.exports['fetch generator: connection closed mid-iteration throws and cleans up'] = async test => {
|
|
181
|
+
const tracker = {
|
|
182
|
+
nextCalls: []
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const run = async (_cmd, _range, _query, opts) => {
|
|
186
|
+
for (let i = 0; i < 3; i++) {
|
|
187
|
+
await new Promise(resolve => {
|
|
188
|
+
opts.onUntaggedFetch({ seq: i + 1, uid: 300 + i }, () => {
|
|
189
|
+
tracker.nextCalls.push(i);
|
|
190
|
+
resolve();
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const ctx = createFetchContext(run);
|
|
197
|
+
|
|
198
|
+
let caught = false;
|
|
199
|
+
try {
|
|
200
|
+
for await (let msg of callFetch(ctx, '1:3', { uid: true })) {
|
|
201
|
+
if (msg.seq === 1) {
|
|
202
|
+
// Simulate connection closing after first message
|
|
203
|
+
ctx.isClosed = true;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
} catch (err) {
|
|
207
|
+
caught = true;
|
|
208
|
+
test.equal(err.code, 'EConnectionClosed', 'should throw EConnectionClosed');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
await new Promise(r => setTimeout(r, 50));
|
|
212
|
+
|
|
213
|
+
test.ok(caught, 'error was caught');
|
|
214
|
+
test.equal(tracker.nextCalls.length, 3, 'all next() callbacks should be called for cleanup');
|
|
215
|
+
test.done();
|
|
216
|
+
};
|