imapflow 1.7.7 → 1.7.8
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
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.7.8](https://github.com/postalsys/imapflow/compare/v1.7.7...v1.7.8) (2026-09-01)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **download:** keep the pipeline error forwarder across a backpressure wait ([c6b2ed7](https://github.com/postalsys/imapflow/commit/c6b2ed7f2ffb598dad0d7a4beb915055c3cfbef6))
|
|
9
|
+
|
|
3
10
|
## [1.7.7](https://github.com/postalsys/imapflow/compare/v1.7.6...v1.7.7) (2026-08-31)
|
|
4
11
|
|
|
5
12
|
|
package/lib/imap-flow.js
CHANGED
|
@@ -2673,6 +2673,10 @@ class ImapFlow extends EventEmitter {
|
|
|
2673
2673
|
// built by guardedPromise(), so its rejection is already observed and cannot trigger
|
|
2674
2674
|
// unhandledRejection. close() is synchronous, so all remaining cleanup runs before
|
|
2675
2675
|
// any microtask rejection handler fires.
|
|
2676
|
+
//
|
|
2677
|
+
// The error travels on, though, through await chains and .then() links that
|
|
2678
|
+
// guardedPromise() knows nothing about. Read a crash stack ending here as "this is
|
|
2679
|
+
// the value that escaped", never as "this is the promise that escaped".
|
|
2676
2680
|
let byeReason = this.byeReason;
|
|
2677
2681
|
|
|
2678
2682
|
for (let request of pendingRequests) {
|
|
@@ -4124,17 +4128,17 @@ class ImapFlow extends EventEmitter {
|
|
|
4124
4128
|
// Wait for drain event before continuing
|
|
4125
4129
|
try {
|
|
4126
4130
|
await new Promise((resolve, reject) => {
|
|
4127
|
-
|
|
4128
|
-
|
|
4131
|
+
// finish() is the listener itself, as settle() is for the TLS upgrade:
|
|
4132
|
+
// 'drain' and 'close' emit no arguments, 'error' emits the error, and
|
|
4133
|
+
// removal needs no separate handler references. It removes only the
|
|
4134
|
+
// three listeners this wait installed - removeAllListeners('error')
|
|
4135
|
+
// also took off the forwarder pipeStage() attached to the head stream
|
|
4136
|
+
// when the pipeline was built, and the head must keep that forwarder
|
|
4137
|
+
// for the life of the download or a chunk failure has nowhere to go.
|
|
4129
4138
|
const finish = err => {
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
// Remove all listeners
|
|
4135
|
-
stream.removeAllListeners('drain');
|
|
4136
|
-
stream.removeAllListeners('error');
|
|
4137
|
-
stream.removeAllListeners('close');
|
|
4139
|
+
for (let event of ['drain', 'error', 'close']) {
|
|
4140
|
+
stream.removeListener(event, finish);
|
|
4141
|
+
}
|
|
4138
4142
|
|
|
4139
4143
|
/* c8 ignore next 2 */ // stream error during a backpressure drain wait is timing-dependent
|
|
4140
4144
|
if (err) {
|
|
@@ -4144,9 +4148,9 @@ class ImapFlow extends EventEmitter {
|
|
|
4144
4148
|
}
|
|
4145
4149
|
};
|
|
4146
4150
|
|
|
4147
|
-
stream.once('drain',
|
|
4148
|
-
stream.once('error',
|
|
4149
|
-
stream.once('close',
|
|
4151
|
+
stream.once('drain', finish);
|
|
4152
|
+
stream.once('error', finish);
|
|
4153
|
+
stream.once('close', finish);
|
|
4150
4154
|
});
|
|
4151
4155
|
/* c8 ignore start */ // re-throw path only triggers on a stream error mid-drain, which is timing-dependent
|
|
4152
4156
|
} catch (err) {
|
|
@@ -4210,7 +4214,14 @@ class ImapFlow extends EventEmitter {
|
|
|
4210
4214
|
if (!fetchAborted && stream && !stream.destroyed) {
|
|
4211
4215
|
stream.end();
|
|
4212
4216
|
}
|
|
4213
|
-
})
|
|
4217
|
+
})
|
|
4218
|
+
// Terminal guard: nothing consumes this chain, so a throw from either handler
|
|
4219
|
+
// above rejects a promise nobody holds and takes the process down on
|
|
4220
|
+
// unhandledRejection. Reaching it always means an invariant broke - the head
|
|
4221
|
+
// stream kept pipeStage()'s error forwarder for the life of the download, so
|
|
4222
|
+
// emit('error') above has somewhere to go - which is why it logs at error even
|
|
4223
|
+
// for a routine-looking connection code.
|
|
4224
|
+
.catch(err => this.log.error({ msg: 'Failed to fail the download stream', err, cid: this.id }));
|
|
4214
4225
|
};
|
|
4215
4226
|
|
|
4216
4227
|
setImmediate(() => {
|
package/package.json
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// One definition instead of a per-file copy, so a change to what the constructor needs lands
|
|
5
5
|
// in one place and cannot drift between suites.
|
|
6
6
|
|
|
7
|
+
const { Writable } = require('node:stream');
|
|
8
|
+
|
|
7
9
|
const { ImapFlow } = require('../../lib/imap-flow');
|
|
8
10
|
|
|
9
11
|
// A socket stub complete enough for setSocketHandlers(), clearSocketHandlers() and close() to
|
|
@@ -54,4 +56,46 @@ const chunkedFetchOne = body => async (range, query) => {
|
|
|
54
56
|
return { uid: 1, size: body.length, source: body.subarray(start, start + maxLength) };
|
|
55
57
|
};
|
|
56
58
|
|
|
57
|
-
|
|
59
|
+
// Installs a process-level unhandledRejection detector for one test. Shared because getting the
|
|
60
|
+
// teardown wrong leaks a process listener into every test that runs after it, and because the
|
|
61
|
+
// assertion has to read the same way wherever a suite checks that nothing escaped.
|
|
62
|
+
const installRejectionDetector = test => {
|
|
63
|
+
// Tracked as a separate flag rather than by testing the reason: a rejection can carry a falsy
|
|
64
|
+
// value, and that is still an escaped rejection.
|
|
65
|
+
let unhandled = false;
|
|
66
|
+
let unhandledReason = null;
|
|
67
|
+
const handler = reason => {
|
|
68
|
+
unhandled = true;
|
|
69
|
+
unhandledReason = reason;
|
|
70
|
+
};
|
|
71
|
+
process.on('unhandledRejection', handler);
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
check() {
|
|
75
|
+
process.removeListener('unhandledRejection', handler);
|
|
76
|
+
test.equal(unhandled, false, 'no unhandledRejection should fire' + (unhandledReason ? ': ' + unhandledReason.message : ''));
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// A Writable that always defers its callback, so writing to a stream piped into it fills the
|
|
82
|
+
// pipeline and forces the producer into backpressure. `delay` is only for tests that also need
|
|
83
|
+
// wall-clock time to pass; deferring at all is what triggers the drain wait.
|
|
84
|
+
const slowConsumer = (options = {}) => {
|
|
85
|
+
let { delay = 0, onChunk } = options;
|
|
86
|
+
return new Writable({
|
|
87
|
+
highWaterMark: 1,
|
|
88
|
+
write(chunk, enc, cb) {
|
|
89
|
+
if (typeof onChunk === 'function') {
|
|
90
|
+
onChunk(chunk);
|
|
91
|
+
}
|
|
92
|
+
if (delay) {
|
|
93
|
+
setTimeout(cb, delay);
|
|
94
|
+
} else {
|
|
95
|
+
setImmediate(cb);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
module.exports = { makeClient, makeIdleReadyClient, makeSocketStub, chunkedFetchOne, installRejectionDetector, slowConsumer };
|
|
@@ -8,8 +8,8 @@ const { ImapFlow } = require('../lib/imap-flow');
|
|
|
8
8
|
const libbase64 = require('libbase64');
|
|
9
9
|
const libqp = require('libqp');
|
|
10
10
|
const libmime = require('libmime');
|
|
11
|
-
const {
|
|
12
|
-
const { chunkedFetchOne } = require('./fixtures/test-client');
|
|
11
|
+
const { finished } = require('stream');
|
|
12
|
+
const { chunkedFetchOne, installRejectionDetector, slowConsumer } = require('./fixtures/test-client');
|
|
13
13
|
|
|
14
14
|
const makeClient = (overrides = {}) => {
|
|
15
15
|
let client = new ImapFlow({
|
|
@@ -547,13 +547,7 @@ module.exports['Download: in-loop backpressure waits for drain'] = async test =>
|
|
|
547
547
|
// one buffer down) but subsequent loop writes return false while the consumer
|
|
548
548
|
// is still draining the previous chunk, exercising the in-loop drain wait.
|
|
549
549
|
let received = 0;
|
|
550
|
-
let slow =
|
|
551
|
-
highWaterMark: 1,
|
|
552
|
-
write(chunk, enc, cb) {
|
|
553
|
-
received += chunk.length;
|
|
554
|
-
setTimeout(cb, 40);
|
|
555
|
-
}
|
|
556
|
-
});
|
|
550
|
+
let slow = slowConsumer({ delay: 40, onChunk: chunk => (received += chunk.length) });
|
|
557
551
|
content.pipe(slow);
|
|
558
552
|
await new Promise(resolve => slow.on('finish', resolve));
|
|
559
553
|
|
|
@@ -587,6 +581,53 @@ module.exports['Download: error during streaming surfaces on content stream'] =
|
|
|
587
581
|
test.done();
|
|
588
582
|
};
|
|
589
583
|
|
|
584
|
+
module.exports['Download: a chunk failure after a backpressure wait reaches the stream'] = async test => {
|
|
585
|
+
// Regression: the drain wait cleared the head stream's listeners with
|
|
586
|
+
// removeAllListeners('error'), which also removed the error forwarder the decoder pipeline
|
|
587
|
+
// attached to it. From the first backpressure wait onwards a later chunk failure was emitted
|
|
588
|
+
// onto a listener-less stream, so emit() threw inside the .catch() handler and the rejection
|
|
589
|
+
// escaped as an unhandledRejection instead of reaching the consumer.
|
|
590
|
+
let client = makeClient();
|
|
591
|
+
let big = Buffer.alloc(64 * 1024, 0x61);
|
|
592
|
+
let calls = 0;
|
|
593
|
+
client.fetchOne = async () => {
|
|
594
|
+
calls++;
|
|
595
|
+
if (calls <= 3) {
|
|
596
|
+
return { uid: 1, size: 400 * 1024, source: big };
|
|
597
|
+
}
|
|
598
|
+
let err = new Error('Connection not available');
|
|
599
|
+
err.code = 'NoConnection';
|
|
600
|
+
throw err;
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
const detector = installRejectionDetector(test);
|
|
604
|
+
|
|
605
|
+
let received = 0;
|
|
606
|
+
let streamErr;
|
|
607
|
+
try {
|
|
608
|
+
let { content } = await client.download('1', false, { uid: true, chunkSize: 64 * 1024 });
|
|
609
|
+
|
|
610
|
+
// Backpressure comes from the deferred callback, not from wall-clock delay
|
|
611
|
+
content.pipe(slowConsumer({ onChunk: chunk => (received += chunk.length) }));
|
|
612
|
+
await new Promise(resolve =>
|
|
613
|
+
finished(content, err => {
|
|
614
|
+
streamErr = err;
|
|
615
|
+
resolve();
|
|
616
|
+
})
|
|
617
|
+
);
|
|
618
|
+
} finally {
|
|
619
|
+
detector.check();
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Without a completed drain wait the regression is not exercised at all, so assert the
|
|
623
|
+
// pipeline actually drained a chunk before the failing fetch rather than inferring it
|
|
624
|
+
test.ok(received >= 64 * 1024, 'a chunk drained through the consumer before the failure');
|
|
625
|
+
test.equal(calls, 4, 'the failing chunk was reached');
|
|
626
|
+
test.ok(streamErr, 'error surfaced on content stream');
|
|
627
|
+
test.equal(streamErr && streamErr.code, 'NoConnection');
|
|
628
|
+
test.done();
|
|
629
|
+
};
|
|
630
|
+
|
|
590
631
|
module.exports['Download: write error on initial chunk surfaces on stream'] = async test => {
|
|
591
632
|
let client = makeClient();
|
|
592
633
|
// a non-Buffer source makes stream.write throw inside the setImmediate try/catch
|
|
@@ -634,12 +675,7 @@ module.exports['Download: aborting mid-backpressure stops the fetch loop'] = asy
|
|
|
634
675
|
|
|
635
676
|
// Slow consumer to force backpressure, then destroy mid-stream so the loop
|
|
636
677
|
// aborts during a drain wait.
|
|
637
|
-
let slow =
|
|
638
|
-
highWaterMark: 1,
|
|
639
|
-
write(chunk, enc, cb) {
|
|
640
|
-
setTimeout(cb, 50);
|
|
641
|
-
}
|
|
642
|
-
});
|
|
678
|
+
let slow = slowConsumer({ delay: 50 });
|
|
643
679
|
content.pipe(slow);
|
|
644
680
|
setTimeout(() => content.destroy(), 70);
|
|
645
681
|
await new Promise(resolve => {
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
const net = require('net');
|
|
20
20
|
const { ImapFlow } = require('../lib/imap-flow');
|
|
21
|
+
const { installRejectionDetector, slowConsumer } = require('./fixtures/test-client');
|
|
21
22
|
|
|
22
23
|
// Create a mock IMAP server with optional custom behavior.
|
|
23
24
|
// options.extraCapabilities - additional capabilities (e.g., 'IDLE')
|
|
@@ -83,24 +84,6 @@ function createMockServer(options) {
|
|
|
83
84
|
return server;
|
|
84
85
|
}
|
|
85
86
|
|
|
86
|
-
// Helper: install an unhandledRejection detector
|
|
87
|
-
function installRejectionDetector(test) {
|
|
88
|
-
let unhandled = false;
|
|
89
|
-
let unhandledReason = null;
|
|
90
|
-
const handler = reason => {
|
|
91
|
-
unhandled = true;
|
|
92
|
-
unhandledReason = reason;
|
|
93
|
-
};
|
|
94
|
-
process.on('unhandledRejection', handler);
|
|
95
|
-
|
|
96
|
-
return {
|
|
97
|
-
check() {
|
|
98
|
-
process.removeListener('unhandledRejection', handler);
|
|
99
|
-
test.equal(unhandled, false, 'no unhandledRejection should fire' + (unhandledReason ? ': ' + unhandledReason.message : ''));
|
|
100
|
-
}
|
|
101
|
-
};
|
|
102
|
-
}
|
|
103
|
-
|
|
104
87
|
// Drives a connection to the one state both IDLE-waiter tests need: IDLE issued but never
|
|
105
88
|
// acknowledged with a "+", so preCheck() cannot send DONE and anything it queues stays queued
|
|
106
89
|
// until close() tears the IDLE command down. Hands the connected client to `run`, and always
|
|
@@ -581,6 +564,91 @@ exports['Unhandled Rejection Prevention'] = {
|
|
|
581
564
|
await new Promise(r => setTimeout(r, 100));
|
|
582
565
|
detector.check();
|
|
583
566
|
|
|
567
|
+
client.close();
|
|
568
|
+
} catch (err) {
|
|
569
|
+
detector.check();
|
|
570
|
+
test.ok(false, 'Unexpected error: ' + err.message);
|
|
571
|
+
} finally {
|
|
572
|
+
server.close(() => test.done());
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
},
|
|
576
|
+
|
|
577
|
+
'a download losing its connection mid-chunk should not cause unhandled rejection (Death 3)'(test) {
|
|
578
|
+
// The reported production crash, end to end over a real socket: a chunked download whose
|
|
579
|
+
// consumer is applying backpressure, and a connection that goes away with the next
|
|
580
|
+
// UID FETCH in flight. close() rejects that pending request, and the rejection has to
|
|
581
|
+
// reach the content stream rather than a promise nobody holds.
|
|
582
|
+
test.expect(4);
|
|
583
|
+
|
|
584
|
+
const CHUNK = 64 * 1024;
|
|
585
|
+
const TOTAL = CHUNK * 8;
|
|
586
|
+
let fetchCount = 0;
|
|
587
|
+
|
|
588
|
+
const server = createMockServer({
|
|
589
|
+
onCommand(socket, tag, command) {
|
|
590
|
+
if (command !== 'UID') {
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
fetchCount++;
|
|
595
|
+
if (fetchCount > 3) {
|
|
596
|
+
// the production event: the socket ends with a UID FETCH pending
|
|
597
|
+
socket.destroy();
|
|
598
|
+
return true;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// one BODY[]<offset> chunk, in the literal form download() asks for
|
|
602
|
+
socket.write(`* 1 FETCH (UID 1 RFC822.SIZE ${TOTAL} BODY[]<${(fetchCount - 1) * CHUNK}> {${CHUNK}}\r\n`);
|
|
603
|
+
socket.write(Buffer.alloc(CHUNK, 0x61));
|
|
604
|
+
socket.write(')\r\n');
|
|
605
|
+
socket.write(`${tag} OK FETCH completed\r\n`);
|
|
606
|
+
return true;
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
server.listen(0, '127.0.0.1', async () => {
|
|
611
|
+
const client = new ImapFlow({
|
|
612
|
+
host: '127.0.0.1',
|
|
613
|
+
port: server.address().port,
|
|
614
|
+
secure: false,
|
|
615
|
+
logger: false,
|
|
616
|
+
disableAutoIdle: true,
|
|
617
|
+
auth: { user: 'test', pass: 'test' }
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
// the connection is destroyed under the client on purpose
|
|
621
|
+
client.on('error', () => false);
|
|
622
|
+
|
|
623
|
+
const detector = installRejectionDetector(test);
|
|
624
|
+
|
|
625
|
+
try {
|
|
626
|
+
await client.connect();
|
|
627
|
+
await client.mailboxOpen('INBOX');
|
|
628
|
+
|
|
629
|
+
let { content } = await client.download('1', false, { uid: true, chunkSize: CHUNK });
|
|
630
|
+
|
|
631
|
+
let received = 0;
|
|
632
|
+
let streamErr = null;
|
|
633
|
+
content.on('error', err => {
|
|
634
|
+
streamErr = err;
|
|
635
|
+
});
|
|
636
|
+
// The delay is load-bearing: it has to outlast a loopback round trip, or the
|
|
637
|
+
// pipeline drains between chunks, writeChunk() never returns false, and the
|
|
638
|
+
// backpressure wait this test exists for is never entered
|
|
639
|
+
content.pipe(slowConsumer({ delay: 30, onChunk: chunk => (received += chunk.length) }));
|
|
640
|
+
|
|
641
|
+
await new Promise(resolve => {
|
|
642
|
+
content.on('close', resolve);
|
|
643
|
+
content.on('error', resolve);
|
|
644
|
+
});
|
|
645
|
+
await new Promise(r => setTimeout(r, 100));
|
|
646
|
+
detector.check();
|
|
647
|
+
|
|
648
|
+
test.ok(received >= CHUNK, 'a chunk drained through the consumer before the connection went away');
|
|
649
|
+
test.ok(streamErr, 'the failure surfaced on the content stream');
|
|
650
|
+
test.equal(streamErr && streamErr.code, 'NoConnection', 'the caller gets the connection error');
|
|
651
|
+
|
|
584
652
|
client.close();
|
|
585
653
|
} catch (err) {
|
|
586
654
|
detector.check();
|