imapflow 1.6.6 → 1.7.0
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 +13 -0
- package/lib/commands/idle.js +26 -5
- package/lib/imap-flow.d.ts +11 -0
- package/lib/imap-flow.js +176 -36
- package/package.json +1 -1
- package/test/auto-idle-test.js +470 -0
- package/test/connection-edge-cases-test.js +3 -1
- package/test/fixtures/test-client.js +57 -0
- package/test/idle-polling-test.js +88 -0
- package/test/imap-flow-coverage-test.js +4 -10
- package/test/imap-flow-fetch-download-test.js +3 -10
- package/test/imap-flow-internals-test.js +14 -32
- package/test/timer-policy-test.js +4 -17
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.7.0](https://github.com/postalsys/imapflow/compare/v1.6.6...v1.7.0) (2026-08-11)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **idle:** make the auto-IDLE delay configurable ([311fe0c](https://github.com/postalsys/imapflow/commit/311fe0ccb0d75d4ddbe797bf5bc755df5bf0485f))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **idle:** align the socket watchdog with the auto-IDLE busy guard ([d5e7191](https://github.com/postalsys/imapflow/commit/d5e71915ac7a1829b7e1340da6195a5e3e9984b2))
|
|
14
|
+
* **idle:** validate autoIdleDelay and keep auto-IDLE off a busy connection ([aeafdf6](https://github.com/postalsys/imapflow/commit/aeafdf62fab3b29cd488d9b8cbdffc8896107d6e))
|
|
15
|
+
|
|
3
16
|
## [1.6.6](https://github.com/postalsys/imapflow/compare/v1.6.5...v1.6.6) (2026-08-07)
|
|
4
17
|
|
|
5
18
|
|
package/lib/commands/idle.js
CHANGED
|
@@ -257,14 +257,16 @@ async function runPollingFallback(connection, maxIdleTime) {
|
|
|
257
257
|
|
|
258
258
|
pollOnce(connection, session)
|
|
259
259
|
.then(() => {
|
|
260
|
+
// Stamped only after a poll actually completed: a failed poll must not
|
|
261
|
+
// satisfy the resumed schedule below, or the next session would defer
|
|
262
|
+
// its first poll a full interval past an attempt that checked nothing.
|
|
263
|
+
connection._lastPollAt = Date.now();
|
|
260
264
|
// Cancellation is re-checked here: the session may have been broken while
|
|
261
265
|
// this poll was in flight, and an orphaned poller must not schedule again.
|
|
262
266
|
if (session.cancelled) {
|
|
263
267
|
return;
|
|
264
268
|
}
|
|
265
|
-
|
|
266
|
-
// Background polling must not keep the process alive
|
|
267
|
-
unrefTimer(session.timer);
|
|
269
|
+
scheduleNextPoll(interval);
|
|
268
270
|
})
|
|
269
271
|
.catch(err => {
|
|
270
272
|
connection.log.warn({ err, cid: connection.id });
|
|
@@ -272,9 +274,28 @@ async function runPollingFallback(connection, maxIdleTime) {
|
|
|
272
274
|
});
|
|
273
275
|
};
|
|
274
276
|
|
|
277
|
+
function scheduleNextPoll(delay) {
|
|
278
|
+
session.timer = setTimeout(runPoll, delay);
|
|
279
|
+
// Background polling must not keep the process alive
|
|
280
|
+
unrefTimer(session.timer);
|
|
281
|
+
}
|
|
282
|
+
|
|
275
283
|
connection.log.debug({ src: 'c', msg: `initiated NOOP loop`, cid: connection.id });
|
|
276
|
-
|
|
277
|
-
|
|
284
|
+
|
|
285
|
+
// Every auto-IDLE restart begins a fresh polling session, so an unconditional first
|
|
286
|
+
// poll would tie the poll rate to how often the caller runs commands rather than to
|
|
287
|
+
// `interval`: with a short autoIdleDelay, a command every few seconds turns into a
|
|
288
|
+
// poll every few seconds. The last poll timestamp lives on the connection, so a new
|
|
289
|
+
// session resumes the previous one's schedule instead of restarting it.
|
|
290
|
+
// Clamped at zero because a backward wall-clock step (NTP, VM resume) leaves the
|
|
291
|
+
// stamp in the future; however large the jump, the next poll must never be more
|
|
292
|
+
// than one full interval away.
|
|
293
|
+
let sinceLastPoll = Math.max(0, Date.now() - (connection._lastPollAt || 0));
|
|
294
|
+
if (sinceLastPoll >= interval) {
|
|
295
|
+
runPoll();
|
|
296
|
+
} else {
|
|
297
|
+
scheduleNextPoll(interval - sinceLastPoll);
|
|
298
|
+
}
|
|
278
299
|
});
|
|
279
300
|
} finally {
|
|
280
301
|
session.cancelled = true;
|
package/lib/imap-flow.d.ts
CHANGED
|
@@ -30,6 +30,17 @@ export interface ImapFlowOptions {
|
|
|
30
30
|
clientInfo?: IdInfoObject;
|
|
31
31
|
/** If true, then do not start IDLE when connection is established */
|
|
32
32
|
disableAutoIdle?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* How long (in ms) the connection has to be inactive before IDLE is started automatically.
|
|
35
|
+
* Keep it above the pause your own code usually leaves between two commands, otherwise every
|
|
36
|
+
* command is followed by an IDLE that the next command has to break, costing two extra
|
|
37
|
+
* round-trips per command. To turn auto-IDLE off use `disableAutoIdle` rather than a very
|
|
38
|
+
* large delay: the value is capped below `socketTimeout`, because auto-IDLE has to start
|
|
39
|
+
* before the inactivity watchdog fires. On servers without IDLE support this controls when
|
|
40
|
+
* the polling fallback starts, not how often it polls - the poll interval is `maxIdleTime`,
|
|
41
|
+
* capped at 2 minutes. Default: 15000 ms.
|
|
42
|
+
*/
|
|
43
|
+
autoIdleDelay?: number;
|
|
33
44
|
/** Additional TLS options (see Node.js TLS documentation) */
|
|
34
45
|
tls?: ConnectionOptions;
|
|
35
46
|
/** Custom logger instance. Set to false to disable logging */
|
package/lib/imap-flow.js
CHANGED
|
@@ -64,6 +64,19 @@ const MAX_THROTTLE_DELAY = 5 * 60 * 1000;
|
|
|
64
64
|
// ImapFlow constructor option `maxLockHoldTime`. Set to 0 or false to disable.
|
|
65
65
|
const HELD_LOCK_WARN_MS = 30 * 60 * 1000;
|
|
66
66
|
|
|
67
|
+
// How long the connection has to stay inactive before auto-IDLE starts. Long enough that a caller
|
|
68
|
+
// running a sequence of commands is not interrupted by an IDLE it immediately has to break.
|
|
69
|
+
// Configurable via the ImapFlow constructor option `autoIdleDelay`.
|
|
70
|
+
const AUTO_IDLE_DELAY = 15 * 1000;
|
|
71
|
+
|
|
72
|
+
// Headroom kept between the auto-IDLE delay and the socket inactivity watchdog, so IDLE reaches
|
|
73
|
+
// the wire before the watchdog can fire. See normalizeAutoIdleDelay().
|
|
74
|
+
const AUTO_IDLE_SOCKET_MARGIN = 1000;
|
|
75
|
+
|
|
76
|
+
// The largest delay setTimeout can honor (2^31 - 1 ms). Anything above fires after 1 ms instead,
|
|
77
|
+
// so the auto-IDLE delay cap has to stay inside this range even when socketTimeout is not.
|
|
78
|
+
const MAX_TIMER_DELAY = 2 ** 31 - 1;
|
|
79
|
+
|
|
67
80
|
const states = {
|
|
68
81
|
NOT_AUTHENTICATED: 0x01,
|
|
69
82
|
AUTHENTICATED: 0x02,
|
|
@@ -71,6 +84,49 @@ const states = {
|
|
|
71
84
|
LOGOUT: 0x04
|
|
72
85
|
};
|
|
73
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Normalizes the configured auto-IDLE delay into a value `setTimeout` can honor. Anything Node
|
|
89
|
+
* would silently turn into a 1ms timer - NaN, a negative number, a value above the 32-bit range -
|
|
90
|
+
* falls back to the default instead, because a 1ms delay means an IDLE/DONE round trip around
|
|
91
|
+
* every single command. The delay is also capped below `socketTimeout`, see AUTO_IDLE_SOCKET_MARGIN.
|
|
92
|
+
*
|
|
93
|
+
* @param {*} value - The configured `autoIdleDelay` option.
|
|
94
|
+
* @param {Number} socketTimeout - The normalized socket inactivity timeout.
|
|
95
|
+
* @param {Object} log - Logger, used to report a value that could not be used as given.
|
|
96
|
+
* @param {String} cid - Connection id for the log entry.
|
|
97
|
+
* @returns {Number} Delay in milliseconds.
|
|
98
|
+
*/
|
|
99
|
+
const normalizeAutoIdleDelay = (value, socketTimeout, log, cid) => {
|
|
100
|
+
const maxDelay = Math.max(0, Math.min(socketTimeout, MAX_TIMER_DELAY) - AUTO_IDLE_SOCKET_MARGIN);
|
|
101
|
+
const configured = value !== undefined && value !== null;
|
|
102
|
+
|
|
103
|
+
// Numeric strings are accepted, because configuration usually arrives from an environment
|
|
104
|
+
// variable or a JSON file. Booleans and blank strings are not: Number() would read them as 0,
|
|
105
|
+
// i.e. "IDLE around every command", the opposite of the "off" they suggest.
|
|
106
|
+
let delay = typeof value === 'number' || (typeof value === 'string' && value.trim()) ? Number(value) : NaN;
|
|
107
|
+
let reason = null;
|
|
108
|
+
|
|
109
|
+
if (!Number.isFinite(delay) || delay < 0) {
|
|
110
|
+
reason = 'not a non-negative finite number';
|
|
111
|
+
delay = AUTO_IDLE_DELAY;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (delay > maxDelay) {
|
|
115
|
+
// An invalid value keeps its own reason: the cap then applies to the fallback default,
|
|
116
|
+
// not to anything the caller asked for.
|
|
117
|
+
reason = reason || `above socketTimeout (${socketTimeout} ms)`;
|
|
118
|
+
delay = maxDelay;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Only an explicitly configured value is worth warning about. Capping the default because the
|
|
122
|
+
// caller picked a short socketTimeout is expected behavior, not a misconfiguration.
|
|
123
|
+
if (configured && reason) {
|
|
124
|
+
log.warn({ msg: 'Adjusted unusable autoIdleDelay option', requested: value, autoIdleDelay: delay, reason, cid });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return Math.floor(delay);
|
|
128
|
+
};
|
|
129
|
+
|
|
74
130
|
/**
|
|
75
131
|
* @typedef {Object} MailboxObject
|
|
76
132
|
* @global
|
|
@@ -188,6 +244,15 @@ class ImapFlow extends EventEmitter {
|
|
|
188
244
|
* @property {Boolean} [disableAutoIdle=false]
|
|
189
245
|
* If `true`, do not start IDLE automatically. Useful when only specific operations are needed.
|
|
190
246
|
*
|
|
247
|
+
* @property {Number} [autoIdleDelay=15000]
|
|
248
|
+
* How long (in milliseconds) the connection has to be inactive before IDLE is started automatically.
|
|
249
|
+
* Keep it above the pause your own code usually leaves between two commands, otherwise every command is
|
|
250
|
+
* followed by an IDLE that the next command has to break, costing two extra round-trips per command.
|
|
251
|
+
* To turn auto-IDLE off entirely use `disableAutoIdle` rather than a very large delay: the value is
|
|
252
|
+
* capped below `socketTimeout`, because auto-IDLE has to start before the inactivity watchdog fires.
|
|
253
|
+
* On servers without IDLE support this controls when the polling fallback starts, not how often it
|
|
254
|
+
* polls - the poll interval is `maxIdleTime`, capped at 2 minutes.
|
|
255
|
+
*
|
|
191
256
|
* @property {Object} [tls]
|
|
192
257
|
* Additional TLS options. For details, see [Node.js TLS connect](https://nodejs.org/api/tls.html#tls_tls_connect_options_callback).
|
|
193
258
|
*
|
|
@@ -438,6 +503,14 @@ class ImapFlow extends EventEmitter {
|
|
|
438
503
|
this.idRequested = false;
|
|
439
504
|
|
|
440
505
|
this.maxIdleTime = this.options.maxIdleTime || false;
|
|
506
|
+
this.autoIdleDelay = normalizeAutoIdleDelay(this.options.autoIdleDelay, this.socketTimeout, this.log, this.id);
|
|
507
|
+
|
|
508
|
+
// Wall-clock time of the last fallback poll, owned by lib/commands/idle.js
|
|
509
|
+
this._lastPollAt = 0;
|
|
510
|
+
|
|
511
|
+
// Download streams still fetching chunks. Counted, not a flag, so overlapping downloads
|
|
512
|
+
// cannot clear each other's suppression of auto-IDLE.
|
|
513
|
+
this._openDownloads = 0;
|
|
441
514
|
this.missingIdleCommand = (this.options.missingIdleCommand || '').toString().toUpperCase().trim() || 'NOOP';
|
|
442
515
|
|
|
443
516
|
this.disableBinary = !!this.options.disableBinary;
|
|
@@ -1252,10 +1325,22 @@ class ImapFlow extends EventEmitter {
|
|
|
1252
1325
|
/**
|
|
1253
1326
|
* Socket timeout event handler.
|
|
1254
1327
|
*
|
|
1255
|
-
*
|
|
1256
|
-
*
|
|
1328
|
+
* A quiet socket is only a dead connection when something was supposed to be talking. An
|
|
1329
|
+
* idling session, a download whose consumer stopped draining, and a held mailbox lock
|
|
1330
|
+
* whose owner is busy between commands are all expected to go quiet, so the handler keeps
|
|
1331
|
+
* such a connection alive with a NOOP instead of tearing it down. An in-flight command is
|
|
1332
|
+
* the opposite: its reply is overdue, a recovery NOOP would only queue up behind it and
|
|
1333
|
+
* never reach the wire, so the timeout is reported as an error. The IDLE command itself is
|
|
1334
|
+
* the one exception - it stays in flight for as long as idling lasts, and run() breaks it
|
|
1335
|
+
* through preCheck() before the NOOP is dispatched.
|
|
1336
|
+
*
|
|
1337
|
+
* IDLE is not restarted here: run() re-arms auto-IDLE once the NOOP settles, and
|
|
1338
|
+
* autoidle() knows whether the connection is actually free for IDLE - an open download or
|
|
1339
|
+
* a held lock keeps just the keepalive, and with disableAutoIdle nothing restarts at all.
|
|
1340
|
+
* If the server is dead the NOOP never settles, and the next timeout fires with the NOOP
|
|
1341
|
+
* as the stuck in-flight command, which lands in the error branch below.
|
|
1257
1342
|
*
|
|
1258
|
-
* @fires ImapFlow#error Emits error event
|
|
1343
|
+
* @fires ImapFlow#error Emits error event if the connection cannot be recovered
|
|
1259
1344
|
*/
|
|
1260
1345
|
this._socketTimeout =
|
|
1261
1346
|
this._socketTimeout ||
|
|
@@ -1263,26 +1348,21 @@ class ImapFlow extends EventEmitter {
|
|
|
1263
1348
|
const err = new Error('Socket timeout');
|
|
1264
1349
|
err.code = 'ETIMEOUT';
|
|
1265
1350
|
|
|
1266
|
-
|
|
1351
|
+
const quietExpected = this.idling || this._openDownloads || this.currentLock;
|
|
1352
|
+
const commandStuck = this.currentRequest && !(this.idling && this.currentRequest.command === 'IDLE');
|
|
1353
|
+
|
|
1354
|
+
if (quietExpected && !commandStuck) {
|
|
1267
1355
|
if (!this.usable || !this.socket || this.socket.destroyed) {
|
|
1268
1356
|
this.emitError(err);
|
|
1269
1357
|
return;
|
|
1270
1358
|
}
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
.then(() => this.idle())
|
|
1278
|
-
.catch(err => {
|
|
1279
|
-
this.log.warn({ msg: 'IDLE recovery failed after timeout', err, cid: this.id });
|
|
1280
|
-
if (!this.isClosed) {
|
|
1281
|
-
this.close();
|
|
1282
|
-
}
|
|
1283
|
-
});
|
|
1359
|
+
this.run('NOOP').catch(err => {
|
|
1360
|
+
this.log.warn({ msg: 'Connection recovery failed after timeout', err, cid: this.id });
|
|
1361
|
+
if (!this.isClosed) {
|
|
1362
|
+
this.close();
|
|
1363
|
+
}
|
|
1364
|
+
});
|
|
1284
1365
|
} else {
|
|
1285
|
-
// Close immediately for non-IDLE operations
|
|
1286
1366
|
this.log.debug({ msg: 'Socket timeout', cid: this.id });
|
|
1287
1367
|
this.emitError(err);
|
|
1288
1368
|
}
|
|
@@ -2120,6 +2200,16 @@ class ImapFlow extends EventEmitter {
|
|
|
2120
2200
|
return range;
|
|
2121
2201
|
}
|
|
2122
2202
|
|
|
2203
|
+
// The single definition of "the connection is not free". A held or queued mailbox lock, a
|
|
2204
|
+
// command in flight or queued, and an open download stream all mean a caller is
|
|
2205
|
+
// mid-sequence: starting IDLE there injects an IDLE/DONE round trip - or, with
|
|
2206
|
+
// `missingIdleCommand` set to SELECT or STATUS, a mailbox poll - between two of that
|
|
2207
|
+
// caller's own commands. Every one of those states ends by calling autoidle() again, so
|
|
2208
|
+
// declining while busy postpones IDLE, it never cancels it.
|
|
2209
|
+
connectionBusy() {
|
|
2210
|
+
return !!(this.currentLock || this.locks.length || this.currentRequest || this.requestQueue.length || this._openDownloads);
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2123
2213
|
// Timer process-liveness policy: connection establishment and greeting deadlines keep the
|
|
2124
2214
|
// process alive, because a caller is waiting on connect() to settle. Background timers
|
|
2125
2215
|
// (auto-IDLE, IDLE restart, fallback polling, throttle back-off, the held-lock diagnostic) are
|
|
@@ -2130,9 +2220,22 @@ class ImapFlow extends EventEmitter {
|
|
|
2130
2220
|
if (this.options.disableAutoIdle || this.state !== this.states.SELECTED) {
|
|
2131
2221
|
return;
|
|
2132
2222
|
}
|
|
2223
|
+
|
|
2224
|
+
if (this.connectionBusy()) {
|
|
2225
|
+
return;
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2133
2228
|
this.idleStartTimer = setTimeout(() => {
|
|
2229
|
+
// Re-checked at fire time: paths that take ownership of the connection clear this
|
|
2230
|
+
// timer, but the guard must not depend on every one of them doing so - a single
|
|
2231
|
+
// missed clearTimeout would inject IDLE between a caller's own commands. Declining
|
|
2232
|
+
// postpones rather than cancels: whatever made the connection busy calls autoidle()
|
|
2233
|
+
// again when it finishes.
|
|
2234
|
+
if (this.state !== this.states.SELECTED || this.connectionBusy()) {
|
|
2235
|
+
return;
|
|
2236
|
+
}
|
|
2134
2237
|
this.idle().catch(err => this.log.warn({ err, cid: this.id }));
|
|
2135
|
-
},
|
|
2238
|
+
}, this.autoIdleDelay);
|
|
2136
2239
|
unrefTimer(this.idleStartTimer);
|
|
2137
2240
|
}
|
|
2138
2241
|
|
|
@@ -3963,6 +4066,23 @@ class ImapFlow extends EventEmitter {
|
|
|
3963
4066
|
}
|
|
3964
4067
|
};
|
|
3965
4068
|
|
|
4069
|
+
// A download is a sequence of chunk FETCHes with a backpressure wait in between. Those
|
|
4070
|
+
// gaps look exactly like an inactive connection, so without this auto-IDLE would start
|
|
4071
|
+
// between chunks and the next chunk would have to break it again - two extra round
|
|
4072
|
+
// trips per chunk, for as long as the consumer is slow. Counted before control returns
|
|
4073
|
+
// to the event loop: the head chunk's own FETCH already armed the auto-IDLE timer, and
|
|
4074
|
+
// with a very short autoIdleDelay that timer could otherwise fire before the deferred
|
|
4075
|
+
// chunk loop below has marked the download open.
|
|
4076
|
+
this._openDownloads++;
|
|
4077
|
+
let downloadDone = false;
|
|
4078
|
+
let finishDownload = () => {
|
|
4079
|
+
if (!downloadDone) {
|
|
4080
|
+
downloadDone = true;
|
|
4081
|
+
this._openDownloads--;
|
|
4082
|
+
this.autoidle();
|
|
4083
|
+
}
|
|
4084
|
+
};
|
|
4085
|
+
|
|
3966
4086
|
// Kick off the download pipeline asynchronously. The first chunk was
|
|
3967
4087
|
// already fetched above (to get metadata); write it to the decoder
|
|
3968
4088
|
// stream and then fetch remaining chunks via fetchAllParts().
|
|
@@ -3987,6 +4107,7 @@ class ImapFlow extends EventEmitter {
|
|
|
3987
4107
|
/* c8 ignore stop */
|
|
3988
4108
|
})
|
|
3989
4109
|
.finally(() => {
|
|
4110
|
+
finishDownload();
|
|
3990
4111
|
if (!fetchAborted && stream && !stream.destroyed) {
|
|
3991
4112
|
stream.end();
|
|
3992
4113
|
}
|
|
@@ -3999,6 +4120,7 @@ class ImapFlow extends EventEmitter {
|
|
|
3999
4120
|
writeResult = writeChunk(chunk);
|
|
4000
4121
|
} catch (err) {
|
|
4001
4122
|
stream.emit('error', err);
|
|
4123
|
+
finishDownload();
|
|
4002
4124
|
/* c8 ignore next 3 */ // emitting the error above triggers cleanup (fetchAborted=true), so this end() guard is already false here
|
|
4003
4125
|
if (!fetchAborted && stream && !stream.destroyed) {
|
|
4004
4126
|
stream.end();
|
|
@@ -4006,12 +4128,14 @@ class ImapFlow extends EventEmitter {
|
|
|
4006
4128
|
return;
|
|
4007
4129
|
}
|
|
4008
4130
|
|
|
4009
|
-
/* c8 ignore next
|
|
4131
|
+
/* c8 ignore next 9 */ // `stream` is piped to the limiter before this runs, so the head write drains synchronously and always returns true (verified for chunkSize up to 8MB); the drain-wait branch is unreachable
|
|
4010
4132
|
if (!writeResult) {
|
|
4011
4133
|
// Initial chunk filled the buffer, wait for drain
|
|
4012
4134
|
stream.once('drain', () => {
|
|
4013
4135
|
if (!fetchAborted) {
|
|
4014
4136
|
runFetchAllParts();
|
|
4137
|
+
} else {
|
|
4138
|
+
finishDownload();
|
|
4015
4139
|
}
|
|
4016
4140
|
});
|
|
4017
4141
|
} else {
|
|
@@ -4182,18 +4306,22 @@ class ImapFlow extends EventEmitter {
|
|
|
4182
4306
|
|
|
4183
4307
|
clearTimeout(this.idleStartTimer);
|
|
4184
4308
|
|
|
4185
|
-
|
|
4186
|
-
|
|
4187
|
-
|
|
4188
|
-
|
|
4189
|
-
|
|
4309
|
+
try {
|
|
4310
|
+
// The preCheck (breaking an active IDLE) sits inside the try on purpose: the
|
|
4311
|
+
// clearTimeout above is unconditional, so every exit - a failed command or a
|
|
4312
|
+
// preCheck that rejects - must still reach the finally, or auto-IDLE would stay
|
|
4313
|
+
// disarmed on an otherwise healthy connection until some later command succeeded.
|
|
4314
|
+
if (typeof this.preCheck === 'function') {
|
|
4315
|
+
await this.preCheck();
|
|
4316
|
+
}
|
|
4190
4317
|
|
|
4191
|
-
|
|
4192
|
-
|
|
4193
|
-
|
|
4318
|
+
return await this.runInternal(command, ...args);
|
|
4319
|
+
} finally {
|
|
4320
|
+
if (command !== 'IDLE') {
|
|
4321
|
+
// do not autostart IDLE, if IDLE itself was stopped
|
|
4322
|
+
this.autoidle();
|
|
4323
|
+
}
|
|
4194
4324
|
}
|
|
4195
|
-
|
|
4196
|
-
return result;
|
|
4197
4325
|
}
|
|
4198
4326
|
|
|
4199
4327
|
/**
|
|
@@ -4314,6 +4442,10 @@ class ImapFlow extends EventEmitter {
|
|
|
4314
4442
|
idling: this.idling
|
|
4315
4443
|
});
|
|
4316
4444
|
this.currentLock = false;
|
|
4445
|
+
// autoidle() will not arm while a lock is held, so the release is what
|
|
4446
|
+
// restarts it. It re-checks the queue itself, so a lock waiting behind
|
|
4447
|
+
// this one still keeps IDLE off.
|
|
4448
|
+
this.autoidle();
|
|
4317
4449
|
// Use setImmediate to avoid stack overflow
|
|
4318
4450
|
setImmediate(() => {
|
|
4319
4451
|
this.processLocks().catch(err => this.log.error({ err, cid: this.id }));
|
|
@@ -4335,6 +4467,18 @@ class ImapFlow extends EventEmitter {
|
|
|
4335
4467
|
continue; // Process next lock in queue
|
|
4336
4468
|
}
|
|
4337
4469
|
|
|
4470
|
+
// Both grant paths finish the same way. autoidle() is re-checked because a stale
|
|
4471
|
+
// auto-IDLE timer may still be armed at this point: on the SELECT path run()
|
|
4472
|
+
// re-arms auto-IDLE when the SELECT settles - a moment before currentLock is set -
|
|
4473
|
+
// and the fast path can inherit a timer from an earlier command. Either way the
|
|
4474
|
+
// timer must not fire inside the lock.
|
|
4475
|
+
const grantLock = () => {
|
|
4476
|
+
this.currentLock = lock;
|
|
4477
|
+
armHeldTimer();
|
|
4478
|
+
this.autoidle();
|
|
4479
|
+
resolve({ path, release });
|
|
4480
|
+
};
|
|
4481
|
+
|
|
4338
4482
|
if (this.mailbox && this.mailbox.path === path && !!this.mailbox.readOnly === !!options.readOnly) {
|
|
4339
4483
|
// Fast path: mailbox is already selected with the right access mode
|
|
4340
4484
|
this.log.trace({
|
|
@@ -4344,9 +4488,7 @@ class ImapFlow extends EventEmitter {
|
|
|
4344
4488
|
idling: this.idling,
|
|
4345
4489
|
...(options.description && { description: options.description })
|
|
4346
4490
|
});
|
|
4347
|
-
|
|
4348
|
-
armHeldTimer();
|
|
4349
|
-
resolve({ path, release });
|
|
4491
|
+
grantLock();
|
|
4350
4492
|
break; // Stop processing; next lock waits for release()
|
|
4351
4493
|
}
|
|
4352
4494
|
|
|
@@ -4360,9 +4502,7 @@ class ImapFlow extends EventEmitter {
|
|
|
4360
4502
|
idling: this.idling,
|
|
4361
4503
|
...(options.description && { description: options.description })
|
|
4362
4504
|
});
|
|
4363
|
-
|
|
4364
|
-
armHeldTimer();
|
|
4365
|
-
resolve({ path, release });
|
|
4505
|
+
grantLock();
|
|
4366
4506
|
break; // Wait for this lock to be released
|
|
4367
4507
|
} catch (err) {
|
|
4368
4508
|
if (err.responseStatus === 'NO') {
|
package/package.json
CHANGED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Auto-IDLE policy: how the `autoIdleDelay` option is normalized, when the timer is allowed to
|
|
4
|
+
// arm at all, and how the socket inactivity watchdog treats connections that are supposed to be
|
|
5
|
+
// quiet.
|
|
6
|
+
//
|
|
7
|
+
// All of it matters more than the option looks. The configured value reaches setTimeout directly,
|
|
8
|
+
// where NaN, a negative number and anything above the 32-bit range all become a 1ms timer, i.e.
|
|
9
|
+
// an IDLE/DONE round trip around every single command. Arming while a caller still owns the
|
|
10
|
+
// connection injects that round trip - or, with `missingIdleCommand` set to SELECT or STATUS, a
|
|
11
|
+
// mailbox poll - between two of that caller's own commands. And the watchdog has to tell a
|
|
12
|
+
// connection that is legitimately quiet (idling, a stalled download, a held lock) from one whose
|
|
13
|
+
// command reply is overdue.
|
|
14
|
+
|
|
15
|
+
const { makeClient, makeIdleReadyClient, chunkedFetchOne } = require('./fixtures/test-client');
|
|
16
|
+
const { withFakeTimers } = require('./fixtures/fake-timers');
|
|
17
|
+
|
|
18
|
+
const DEFAULT_DELAY = 15 * 1000;
|
|
19
|
+
const DEFAULT_SOCKET_TIMEOUT = 5 * 60 * 1000;
|
|
20
|
+
const SOCKET_MARGIN = 1000;
|
|
21
|
+
const TIMEOUT_MAX = 2 ** 31 - 1;
|
|
22
|
+
|
|
23
|
+
// Captures warn entries, so the "silently ignored configuration" cases can be asserted as
|
|
24
|
+
// reported rather than guessed at.
|
|
25
|
+
const makeLoggingClient = (overrides = {}) => {
|
|
26
|
+
let warnings = [];
|
|
27
|
+
let client = makeClient({
|
|
28
|
+
...overrides,
|
|
29
|
+
logger: { trace() {}, debug() {}, info() {}, warn: entry => warnings.push(entry), error() {}, fatal() {} }
|
|
30
|
+
});
|
|
31
|
+
return { client, warnings };
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// ============================================================================
|
|
35
|
+
// option normalization
|
|
36
|
+
// ============================================================================
|
|
37
|
+
|
|
38
|
+
module.exports['Auto-IDLE: an unset delay uses the default'] = test => {
|
|
39
|
+
let { client, warnings } = makeLoggingClient();
|
|
40
|
+
test.equal(client.autoIdleDelay, DEFAULT_DELAY);
|
|
41
|
+
test.equal(warnings.length, 0, 'the default is not a misconfiguration');
|
|
42
|
+
test.done();
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
module.exports['Auto-IDLE: a configured delay is honored and reaches setTimeout'] = async test => {
|
|
46
|
+
await withFakeTimers(async timers => {
|
|
47
|
+
let client = makeIdleReadyClient({ autoIdleDelay: 1234 });
|
|
48
|
+
test.equal(client.autoIdleDelay, 1234);
|
|
49
|
+
|
|
50
|
+
client.autoidle();
|
|
51
|
+
|
|
52
|
+
let armed = timers.pending();
|
|
53
|
+
test.equal(armed.length, 1, 'exactly one auto-IDLE timer is armed');
|
|
54
|
+
test.equal(armed[0].delay, 1234, 'the configured delay is what the timer uses');
|
|
55
|
+
|
|
56
|
+
client.close();
|
|
57
|
+
});
|
|
58
|
+
test.done();
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
module.exports['Auto-IDLE: numeric strings are accepted'] = test => {
|
|
62
|
+
// The normal shape of a value coming from an environment variable or a JSON/YAML config file
|
|
63
|
+
test.equal(makeClient({ autoIdleDelay: '2000' }).autoIdleDelay, 2000);
|
|
64
|
+
test.equal(makeClient({ autoIdleDelay: ' 2000 ' }).autoIdleDelay, 2000);
|
|
65
|
+
test.done();
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
module.exports['Auto-IDLE: zero is honored, fractions are floored'] = test => {
|
|
69
|
+
test.equal(makeClient({ autoIdleDelay: 0 }).autoIdleDelay, 0);
|
|
70
|
+
test.equal(makeClient({ autoIdleDelay: 1500.9 }).autoIdleDelay, 1500);
|
|
71
|
+
test.done();
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
module.exports['Auto-IDLE: unusable values fall back to the default and are reported'] = test => {
|
|
75
|
+
// Every one of these is something setTimeout would turn into a 1ms timer, or something a
|
|
76
|
+
// caller plausibly means as "off" - which is what disableAutoIdle is for.
|
|
77
|
+
for (let value of [NaN, -1, -0.5, Infinity, -Infinity, 'soon', '', ' ', true, false, {}, []]) {
|
|
78
|
+
let { client, warnings } = makeLoggingClient({ autoIdleDelay: value });
|
|
79
|
+
test.equal(client.autoIdleDelay, DEFAULT_DELAY, `${String(value)} falls back to the default`);
|
|
80
|
+
test.equal(warnings.length, 1, `${String(value)} is reported rather than silently swallowed`);
|
|
81
|
+
test.equal(warnings[0].msg, 'Adjusted unusable autoIdleDelay option');
|
|
82
|
+
test.equal(warnings[0].reason, 'not a non-negative finite number', `${String(value)} is reported with the right reason`);
|
|
83
|
+
}
|
|
84
|
+
test.done();
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
module.exports['Auto-IDLE: the delay is capped below socketTimeout'] = test => {
|
|
88
|
+
// A delay at or above socketTimeout means the inactivity watchdog fires before IDLE ever
|
|
89
|
+
// starts. `idling` is still false at that point, so the handler emits ETIMEOUT and tears down
|
|
90
|
+
// a quiet but perfectly healthy connection instead of letting it enter IDLE.
|
|
91
|
+
let { client, warnings } = makeLoggingClient({ autoIdleDelay: 10 * 60 * 1000 });
|
|
92
|
+
test.equal(client.autoIdleDelay, DEFAULT_SOCKET_TIMEOUT - SOCKET_MARGIN);
|
|
93
|
+
test.equal(warnings.length, 1, 'the caller is told the value was capped');
|
|
94
|
+
|
|
95
|
+
// The cap follows a custom socketTimeout, and keeps the delay inside the range setTimeout can
|
|
96
|
+
// represent: 2 ** 31 and larger would otherwise silently become a 1ms timer.
|
|
97
|
+
test.equal(makeClient({ autoIdleDelay: 60000, socketTimeout: 30000 }).autoIdleDelay, 29000);
|
|
98
|
+
test.equal(makeClient({ autoIdleDelay: 2 ** 31 }).autoIdleDelay, DEFAULT_SOCKET_TIMEOUT - SOCKET_MARGIN);
|
|
99
|
+
test.equal(makeClient({ autoIdleDelay: Number.MAX_SAFE_INTEGER }).autoIdleDelay, DEFAULT_SOCKET_TIMEOUT - SOCKET_MARGIN);
|
|
100
|
+
test.done();
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
module.exports['Auto-IDLE: the cap stays inside the timer range for a huge socketTimeout'] = test => {
|
|
104
|
+
// socket.setTimeout truncates an over-range socketTimeout on its own, so a huge watchdog
|
|
105
|
+
// deadline still works - but the auto-IDLE delay must not inherit the raw value, or
|
|
106
|
+
// setTimeout would turn it into a 1ms timer and IDLE would follow every single command.
|
|
107
|
+
test.equal(makeClient({ socketTimeout: 2 ** 32, autoIdleDelay: 2 ** 31 }).autoIdleDelay, TIMEOUT_MAX - SOCKET_MARGIN);
|
|
108
|
+
test.equal(makeClient({ socketTimeout: 2200000000, autoIdleDelay: 2150000000 }).autoIdleDelay, TIMEOUT_MAX - SOCKET_MARGIN);
|
|
109
|
+
test.done();
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
module.exports['Auto-IDLE: capping the default for a short socketTimeout is silent'] = test => {
|
|
113
|
+
let { client, warnings } = makeLoggingClient({ socketTimeout: 5000 });
|
|
114
|
+
test.equal(client.autoIdleDelay, 4000, 'the default is capped too');
|
|
115
|
+
test.equal(warnings.length, 0, 'but nothing was misconfigured, so nothing is reported');
|
|
116
|
+
test.done();
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
module.exports['Auto-IDLE: an invalid value keeps its own reason when the default is then capped'] = test => {
|
|
120
|
+
// 'soon' falls back to the 15s default, which a 5s socketTimeout then caps to 4s. The
|
|
121
|
+
// warning must still name what was wrong with the configured value - the cap applied to
|
|
122
|
+
// the fallback, not to anything the caller asked for.
|
|
123
|
+
let { client, warnings } = makeLoggingClient({ autoIdleDelay: 'soon', socketTimeout: 5000 });
|
|
124
|
+
test.equal(client.autoIdleDelay, 4000);
|
|
125
|
+
test.equal(warnings.length, 1);
|
|
126
|
+
test.equal(warnings[0].reason, 'not a non-negative finite number');
|
|
127
|
+
test.done();
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// ============================================================================
|
|
131
|
+
// when the timer may arm
|
|
132
|
+
// ============================================================================
|
|
133
|
+
|
|
134
|
+
module.exports['Auto-IDLE: no timer is armed while a mailbox lock is held'] = async test => {
|
|
135
|
+
await withFakeTimers(async timers => {
|
|
136
|
+
let client = makeIdleReadyClient({ autoIdleDelay: 200 });
|
|
137
|
+
|
|
138
|
+
let lock = await client.getMailboxLock('INBOX');
|
|
139
|
+
test.ok(client.currentLock, 'the lock was granted through the fast path');
|
|
140
|
+
|
|
141
|
+
client.autoidle();
|
|
142
|
+
test.equal(timers.count(), 0, 'IDLE must not be injected between a lock holder own commands');
|
|
143
|
+
|
|
144
|
+
lock.release();
|
|
145
|
+
let armed = timers.pending();
|
|
146
|
+
test.equal(armed.length, 1, 'releasing the lock re-arms auto-IDLE');
|
|
147
|
+
test.equal(armed[0].delay, 200);
|
|
148
|
+
|
|
149
|
+
client.close();
|
|
150
|
+
});
|
|
151
|
+
test.done();
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
module.exports['Auto-IDLE: acquiring a lock through SELECT leaves no timer behind'] = async test => {
|
|
155
|
+
await withFakeTimers(async timers => {
|
|
156
|
+
let client = makeIdleReadyClient({ autoIdleDelay: 200 });
|
|
157
|
+
client.mailbox = false;
|
|
158
|
+
// The SELECT that opens the mailbox goes through run(), which re-arms auto-IDLE when it
|
|
159
|
+
// settles - a moment before currentLock is set. That timer would fire inside the lock.
|
|
160
|
+
client.mailboxOpen = async path => {
|
|
161
|
+
client.mailbox = { path, readOnly: false };
|
|
162
|
+
client.autoidle();
|
|
163
|
+
return client.mailbox;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
let lock = await client.getMailboxLock('INBOX');
|
|
167
|
+
test.ok(client.currentLock, 'the lock was granted through the SELECT path');
|
|
168
|
+
test.equal(timers.count(), 0, 'the timer armed while opening the mailbox was cleared');
|
|
169
|
+
|
|
170
|
+
lock.release();
|
|
171
|
+
test.equal(timers.count(), 1, 'and auto-IDLE resumes once the lock is released');
|
|
172
|
+
|
|
173
|
+
client.close();
|
|
174
|
+
});
|
|
175
|
+
test.done();
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
module.exports['Auto-IDLE: releasing a lock with another one queued does not arm'] = async test => {
|
|
179
|
+
await withFakeTimers(async timers => {
|
|
180
|
+
let client = makeIdleReadyClient({ autoIdleDelay: 200 });
|
|
181
|
+
|
|
182
|
+
let lock = await client.getMailboxLock('INBOX');
|
|
183
|
+
// Queue a second request; the connection is not free when the first holder lets go
|
|
184
|
+
let queued = client.getMailboxLock('INBOX');
|
|
185
|
+
test.equal(client.locks.length, 1, 'the second request is waiting');
|
|
186
|
+
|
|
187
|
+
lock.release();
|
|
188
|
+
test.equal(timers.count(), 0, 'the next holder owns the connection, so IDLE stays off');
|
|
189
|
+
|
|
190
|
+
(await queued).release();
|
|
191
|
+
test.equal(timers.count(), 1, 'the last release re-arms auto-IDLE');
|
|
192
|
+
|
|
193
|
+
client.close();
|
|
194
|
+
});
|
|
195
|
+
test.done();
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
module.exports['Auto-IDLE: no timer is armed while a command is in flight or queued'] = async test => {
|
|
199
|
+
await withFakeTimers(async timers => {
|
|
200
|
+
let client = makeIdleReadyClient({ autoIdleDelay: 200 });
|
|
201
|
+
|
|
202
|
+
// Concurrent commands each clear the timer on entry, so without this guard the first one
|
|
203
|
+
// to settle would arm a timer that fires while the others are still running.
|
|
204
|
+
client.currentRequest = { tag: 'A001', command: 'FETCH', sent: true };
|
|
205
|
+
client.autoidle();
|
|
206
|
+
test.equal(timers.count(), 0, 'a command in flight owns the connection');
|
|
207
|
+
|
|
208
|
+
client.currentRequest = false;
|
|
209
|
+
client.requestQueue = [{ tag: 'A002', command: 'FETCH' }];
|
|
210
|
+
client.autoidle();
|
|
211
|
+
test.equal(timers.count(), 0, 'so does a command still waiting in the queue');
|
|
212
|
+
|
|
213
|
+
client.requestQueue = [];
|
|
214
|
+
client.autoidle();
|
|
215
|
+
test.equal(timers.count(), 1, 'an idle connection arms the timer');
|
|
216
|
+
|
|
217
|
+
client.close();
|
|
218
|
+
});
|
|
219
|
+
test.done();
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
module.exports['Auto-IDLE: the armed timer re-checks the busy guard when it fires'] = async test => {
|
|
223
|
+
await withFakeTimers(async timers => {
|
|
224
|
+
let client = makeIdleReadyClient({ autoIdleDelay: 200 });
|
|
225
|
+
let idleCalls = 0;
|
|
226
|
+
client.idle = async () => {
|
|
227
|
+
idleCalls++;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
client.autoidle();
|
|
231
|
+
test.equal(timers.count(), 1, 'the timer was armed on a free connection');
|
|
232
|
+
|
|
233
|
+
// Ownership taken without clearing the timer - the guard must not depend on every
|
|
234
|
+
// ownership-taking path remembering its clearTimeout.
|
|
235
|
+
client.currentRequest = { tag: 'A001', command: 'FETCH', sent: true };
|
|
236
|
+
await timers.fire();
|
|
237
|
+
test.equal(idleCalls, 0, 'a connection that is busy at fire time does not start IDLE');
|
|
238
|
+
|
|
239
|
+
client.currentRequest = false;
|
|
240
|
+
client.autoidle();
|
|
241
|
+
await timers.fire();
|
|
242
|
+
test.equal(idleCalls, 1, 'a connection that is free at fire time does');
|
|
243
|
+
|
|
244
|
+
client.close();
|
|
245
|
+
});
|
|
246
|
+
test.done();
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
module.exports['Auto-IDLE: no timer is armed while a download is streaming'] = async test => {
|
|
250
|
+
await withFakeTimers(async timers => {
|
|
251
|
+
let client = makeIdleReadyClient({ autoIdleDelay: 200 });
|
|
252
|
+
let body = Buffer.from('A'.repeat(20));
|
|
253
|
+
let armedAfterChunk = [];
|
|
254
|
+
|
|
255
|
+
let serveChunk = chunkedFetchOne(body);
|
|
256
|
+
client.fetchOne = async (range, query) => {
|
|
257
|
+
// Stands in for run(), which clears the auto-IDLE timer on entry and re-arms it once
|
|
258
|
+
// the command settles. The pause between two chunks is where IDLE would slip in.
|
|
259
|
+
clearTimeout(client.idleStartTimer);
|
|
260
|
+
let part = await serveChunk(range, query);
|
|
261
|
+
client.autoidle();
|
|
262
|
+
armedAfterChunk.push(timers.count());
|
|
263
|
+
return part;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
let { content } = await client.download('1', false, { chunkSize: 4 });
|
|
267
|
+
let received = [];
|
|
268
|
+
for await (let chunk of content) {
|
|
269
|
+
received.push(chunk);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
test.equal(Buffer.concat(received).toString(), 'A'.repeat(20), 'the whole body arrived');
|
|
273
|
+
test.ok(armedAfterChunk.length > 2, 'the body really was fetched in several chunks');
|
|
274
|
+
test.deepEqual(
|
|
275
|
+
armedAfterChunk.slice(1).filter(count => count !== 0),
|
|
276
|
+
[],
|
|
277
|
+
'no chunk boundary inside the download armed an auto-IDLE timer'
|
|
278
|
+
);
|
|
279
|
+
test.equal(timers.count(), 1, 'auto-IDLE resumes once the download stream is finished');
|
|
280
|
+
|
|
281
|
+
client.close();
|
|
282
|
+
});
|
|
283
|
+
test.done();
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
module.exports['Auto-IDLE: a download counts as busy before control returns to the event loop'] = async test => {
|
|
287
|
+
await withFakeTimers(async () => {
|
|
288
|
+
let client = makeIdleReadyClient({ autoIdleDelay: 200 });
|
|
289
|
+
let body = Buffer.from('B'.repeat(12));
|
|
290
|
+
client.fetchOne = chunkedFetchOne(body);
|
|
291
|
+
|
|
292
|
+
let { content } = await client.download('1', false, { chunkSize: 4 });
|
|
293
|
+
// The head chunk's own FETCH re-arms auto-IDLE a moment before the download exists;
|
|
294
|
+
// with a very short delay that timer fires before the deferred chunk loop starts, so
|
|
295
|
+
// the busy guard must already see the download when download() hands the stream back.
|
|
296
|
+
test.equal(client._openDownloads, 1, 'the download is counted before streaming starts');
|
|
297
|
+
|
|
298
|
+
let received = [];
|
|
299
|
+
for await (let chunk of content) {
|
|
300
|
+
received.push(chunk);
|
|
301
|
+
}
|
|
302
|
+
test.equal(Buffer.concat(received).toString(), 'B'.repeat(12), 'the whole body arrived');
|
|
303
|
+
test.equal(client._openDownloads, 0, 'the download is released once the stream is done');
|
|
304
|
+
|
|
305
|
+
client.close();
|
|
306
|
+
});
|
|
307
|
+
test.done();
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
module.exports['Auto-IDLE: a failing command still re-arms the timer'] = async test => {
|
|
311
|
+
await withFakeTimers(async timers => {
|
|
312
|
+
let client = makeIdleReadyClient({ autoIdleDelay: 300 });
|
|
313
|
+
// run() clears the timer before dispatching, so re-arming only on the success path would
|
|
314
|
+
// leave auto-IDLE off for good after one rejected command.
|
|
315
|
+
client.runInternal = async () => {
|
|
316
|
+
throw new Error('command failed');
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
let err;
|
|
320
|
+
try {
|
|
321
|
+
await client.run('NOOP');
|
|
322
|
+
} catch (E) {
|
|
323
|
+
err = E;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
test.ok(err, 'the failure still reaches the caller');
|
|
327
|
+
let armed = timers.pending();
|
|
328
|
+
test.equal(armed.length, 1, 'auto-IDLE is re-armed after a failed command');
|
|
329
|
+
test.equal(armed[0].delay, 300);
|
|
330
|
+
|
|
331
|
+
client.close();
|
|
332
|
+
});
|
|
333
|
+
test.done();
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
module.exports['Auto-IDLE: a failing IDLE break still re-arms the timer'] = async test => {
|
|
337
|
+
await withFakeTimers(async timers => {
|
|
338
|
+
let client = makeIdleReadyClient({ autoIdleDelay: 300 });
|
|
339
|
+
// run() awaits preCheck() (breaking an active IDLE) before dispatching. A break that
|
|
340
|
+
// rejects exits run() before the command ever starts, and must still re-arm auto-IDLE
|
|
341
|
+
// exactly like a failed command does.
|
|
342
|
+
client.preCheck = async () => {
|
|
343
|
+
throw new Error('IDLE break failed');
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
let err;
|
|
347
|
+
try {
|
|
348
|
+
await client.run('NOOP');
|
|
349
|
+
} catch (E) {
|
|
350
|
+
err = E;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
test.ok(err, 'the failure still reaches the caller');
|
|
354
|
+
let armed = timers.pending();
|
|
355
|
+
test.equal(armed.length, 1, 'auto-IDLE is re-armed after the failed IDLE break');
|
|
356
|
+
test.equal(armed[0].delay, 300);
|
|
357
|
+
|
|
358
|
+
client.close();
|
|
359
|
+
});
|
|
360
|
+
test.done();
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
// ============================================================================
|
|
364
|
+
// the socket inactivity watchdog
|
|
365
|
+
// ============================================================================
|
|
366
|
+
|
|
367
|
+
// A client wired up for _socketTimeout() with recording stubs: recovery commands land in
|
|
368
|
+
// `recovered`, emitted errors in `errors`. run() is stubbed, so nothing here re-arms auto-IDLE -
|
|
369
|
+
// which is deliberate: the handler itself must never start IDLE, that decision belongs to
|
|
370
|
+
// autoidle() once the recovery NOOP settles for real.
|
|
371
|
+
const makeWatchdogClient = () => {
|
|
372
|
+
let client = makeIdleReadyClient();
|
|
373
|
+
let errors = [];
|
|
374
|
+
let recovered = [];
|
|
375
|
+
client.on('error', err => errors.push(err));
|
|
376
|
+
client.run = async command => recovered.push(command);
|
|
377
|
+
client.idle = async () => recovered.push('IDLE');
|
|
378
|
+
client.writeSocket = client.socket;
|
|
379
|
+
client.setSocketHandlers();
|
|
380
|
+
return { client, errors, recovered };
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
const drainImmediate = () => new Promise(resolve => setImmediate(resolve));
|
|
384
|
+
|
|
385
|
+
module.exports['Auto-IDLE: a stalled download survives the socket watchdog'] = async test => {
|
|
386
|
+
// Suppressing auto-IDLE during a download means `idling` is false when the inactivity
|
|
387
|
+
// watchdog fires, so without the download clause the watchdog would tear down a connection
|
|
388
|
+
// whose only problem is a consumer that stopped draining.
|
|
389
|
+
let { client, errors, recovered } = makeWatchdogClient();
|
|
390
|
+
client._openDownloads = 1;
|
|
391
|
+
|
|
392
|
+
client._socketTimeout();
|
|
393
|
+
await drainImmediate();
|
|
394
|
+
|
|
395
|
+
test.deepEqual(errors, [], 'no ETIMEOUT is emitted while a download is open');
|
|
396
|
+
test.deepEqual(recovered, ['NOOP'], 'kept alive with a NOOP only - IDLE mid-download is what the busy guard exists to prevent');
|
|
397
|
+
|
|
398
|
+
client._openDownloads = 0;
|
|
399
|
+
client._socketTimeout();
|
|
400
|
+
test.equal(errors.length, 1, 'once the download is done a quiet socket is a timeout again');
|
|
401
|
+
test.equal(errors[0].code, 'ETIMEOUT');
|
|
402
|
+
|
|
403
|
+
client.close();
|
|
404
|
+
test.ok(client.isClosed, 'the socket stub supports everything close() needs');
|
|
405
|
+
test.done();
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
module.exports['Auto-IDLE: a stuck chunk FETCH mid-download is a dead connection'] = async test => {
|
|
409
|
+
// A quiet socket while a command is awaiting its reply means the reply is overdue. A
|
|
410
|
+
// recovery NOOP would only queue behind the stuck command and never reach the wire, so
|
|
411
|
+
// the watchdog must report the timeout instead of recovering into a silent hang.
|
|
412
|
+
let { client, errors, recovered } = makeWatchdogClient();
|
|
413
|
+
client._openDownloads = 1;
|
|
414
|
+
client.currentRequest = { tag: 'A001', command: 'FETCH', sent: true };
|
|
415
|
+
|
|
416
|
+
client._socketTimeout();
|
|
417
|
+
await drainImmediate();
|
|
418
|
+
|
|
419
|
+
test.deepEqual(recovered, [], 'no recovery is attempted behind a stuck command');
|
|
420
|
+
test.equal(errors.length, 1, 'the caller learns the connection is dead');
|
|
421
|
+
test.equal(errors[0].code, 'ETIMEOUT');
|
|
422
|
+
|
|
423
|
+
client.close();
|
|
424
|
+
test.done();
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
module.exports['Auto-IDLE: a held mailbox lock keeps a quiet connection alive'] = async test => {
|
|
428
|
+
// A lock holder pausing between commands for longer than socketTimeout is legitimate (the
|
|
429
|
+
// held-lock diagnostic warns only after 30 minutes), and with auto-IDLE declining to arm
|
|
430
|
+
// during a lock there is no IDLE traffic to keep the socket busy - the watchdog has to.
|
|
431
|
+
let { client, errors, recovered } = makeWatchdogClient();
|
|
432
|
+
client.currentLock = { lockId: 1 };
|
|
433
|
+
|
|
434
|
+
client._socketTimeout();
|
|
435
|
+
await drainImmediate();
|
|
436
|
+
|
|
437
|
+
test.deepEqual(errors, [], 'the lock holder keeps its connection');
|
|
438
|
+
test.deepEqual(recovered, ['NOOP'], 'kept alive with a NOOP only, no IDLE inside the lock');
|
|
439
|
+
|
|
440
|
+
client.currentLock = false;
|
|
441
|
+
client._socketTimeout();
|
|
442
|
+
test.equal(errors.length, 1, 'with the lock gone a quiet socket is a timeout again');
|
|
443
|
+
test.equal(errors[0].code, 'ETIMEOUT');
|
|
444
|
+
|
|
445
|
+
client.close();
|
|
446
|
+
test.done();
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
module.exports['Auto-IDLE: idling recovers even with the IDLE command in flight'] = async test => {
|
|
450
|
+
// During true IDLE the in-flight command IS the IDLE command; run() breaks it through
|
|
451
|
+
// preCheck() before the recovery NOOP goes out, so it does not count as stuck. A recovery
|
|
452
|
+
// NOOP that then never settles does: the next timeout must fail rather than queue another.
|
|
453
|
+
let { client, errors, recovered } = makeWatchdogClient();
|
|
454
|
+
client.idling = true;
|
|
455
|
+
client.currentRequest = { tag: 'A001', command: 'IDLE', sent: true };
|
|
456
|
+
|
|
457
|
+
client._socketTimeout();
|
|
458
|
+
await drainImmediate();
|
|
459
|
+
test.deepEqual(errors, [], 'the idling connection is not torn down');
|
|
460
|
+
test.deepEqual(recovered, ['NOOP'], 'it is recovered with a NOOP');
|
|
461
|
+
|
|
462
|
+
client.idling = false;
|
|
463
|
+
client.currentRequest = { tag: 'A002', command: 'NOOP', sent: true };
|
|
464
|
+
client._socketTimeout();
|
|
465
|
+
test.equal(errors.length, 1, 'a recovery NOOP that never settled is a dead connection');
|
|
466
|
+
test.equal(errors[0].code, 'ETIMEOUT');
|
|
467
|
+
|
|
468
|
+
client.close();
|
|
469
|
+
test.done();
|
|
470
|
+
};
|
|
@@ -254,7 +254,9 @@ module.exports['Connection Edge: Socket timeout during IDLE'] = test => {
|
|
|
254
254
|
// Give async operations time to complete
|
|
255
255
|
setTimeout(() => {
|
|
256
256
|
test.ok(noopCalled, 'NOOP should be called to recover from IDLE timeout');
|
|
257
|
-
|
|
257
|
+
// Returning to IDLE is autoidle()'s decision once the NOOP settles (run() re-arms it);
|
|
258
|
+
// the watchdog handler itself must not bypass the busy guard by calling idle() directly
|
|
259
|
+
test.equal(idleCalled, false, 'the timeout handler does not restart IDLE by itself');
|
|
258
260
|
test.done();
|
|
259
261
|
}, 100);
|
|
260
262
|
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Shared ImapFlow construction for unit tests that poke at internals without a live server.
|
|
4
|
+
// One definition instead of a per-file copy, so a change to what the constructor needs lands
|
|
5
|
+
// in one place and cannot drift between suites.
|
|
6
|
+
|
|
7
|
+
const { ImapFlow } = require('../../lib/imap-flow');
|
|
8
|
+
|
|
9
|
+
// A socket stub complete enough for setSocketHandlers(), clearSocketHandlers() and close() to
|
|
10
|
+
// run against. removeListener/removeAllListeners matter most: close() removes the handlers it
|
|
11
|
+
// installed, and a stub without them makes close() throw half-way through teardown with the
|
|
12
|
+
// client still holding its socket and streamer references.
|
|
13
|
+
const makeSocketStub = () => ({
|
|
14
|
+
destroyed: false,
|
|
15
|
+
destroy: () => {},
|
|
16
|
+
on: () => {},
|
|
17
|
+
once: () => {},
|
|
18
|
+
end: () => {},
|
|
19
|
+
unpipe: () => {},
|
|
20
|
+
removeListener: () => {},
|
|
21
|
+
removeAllListeners: () => {},
|
|
22
|
+
setKeepAlive: () => {},
|
|
23
|
+
setTimeout: () => {}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Bare construction: no socket, no state overrides. Tests that need a transport use
|
|
27
|
+
// makeSocketStub() or makeIdleReadyClient() instead of reaching for their own object literals.
|
|
28
|
+
const makeClient = (overrides = {}) =>
|
|
29
|
+
new ImapFlow({
|
|
30
|
+
host: '127.0.0.1',
|
|
31
|
+
port: 993,
|
|
32
|
+
logger: false,
|
|
33
|
+
auth: { user: 'test', pass: 'secret' },
|
|
34
|
+
...overrides
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// A client parked in SELECTED with a stubbed socket and a no-op idle(), ready for autoidle(),
|
|
38
|
+
// lock handling and watchdog behavior to be exercised directly
|
|
39
|
+
const makeIdleReadyClient = (overrides = {}) => {
|
|
40
|
+
let client = makeClient({ maxLockHoldTime: 0, ...overrides });
|
|
41
|
+
client.socket = makeSocketStub();
|
|
42
|
+
client.usable = true;
|
|
43
|
+
client.state = client.states.SELECTED;
|
|
44
|
+
client.mailbox = { path: 'INBOX', readOnly: false };
|
|
45
|
+
client.idle = async () => {};
|
|
46
|
+
return client;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// A fetchOne stand-in that serves `body` back in download()'s chunk-query shape
|
|
50
|
+
// ({source: {start, maxLength}}), so download tests do not each restate that contract
|
|
51
|
+
const chunkedFetchOne = body => async (range, query) => {
|
|
52
|
+
let start = query.source.start;
|
|
53
|
+
let maxLength = query.source.maxLength;
|
|
54
|
+
return { uid: 1, size: body.length, source: body.subarray(start, start + maxLength) };
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
module.exports = { makeClient, makeIdleReadyClient, makeSocketStub, chunkedFetchOne };
|
|
@@ -428,3 +428,91 @@ module.exports['Polling: a break in the initiation tick prevents the first poll'
|
|
|
428
428
|
test.done();
|
|
429
429
|
});
|
|
430
430
|
};
|
|
431
|
+
|
|
432
|
+
module.exports['Polling: a restarted session resumes the schedule instead of polling again'] = async test => {
|
|
433
|
+
await withFakeTimers(async timers => {
|
|
434
|
+
let connection = createConnection();
|
|
435
|
+
|
|
436
|
+
let first = idleCommand(connection, 60000);
|
|
437
|
+
await timers.drain();
|
|
438
|
+
test.deepEqual(connection.commands, ['NOOP'], 'the first session polls immediately');
|
|
439
|
+
|
|
440
|
+
await connection.preCheck();
|
|
441
|
+
await first;
|
|
442
|
+
|
|
443
|
+
// Auto-IDLE restarts the loop after every caller command. An unconditional first poll here
|
|
444
|
+
// would tie the poll rate to how often the caller runs commands instead of to the poll
|
|
445
|
+
// interval - with a short autoIdleDelay, a command every few seconds means a poll every
|
|
446
|
+
// few seconds.
|
|
447
|
+
let second = idleCommand(connection, 60000);
|
|
448
|
+
await timers.drain();
|
|
449
|
+
test.deepEqual(connection.commands, ['NOOP'], 'the restarted session does not poll again');
|
|
450
|
+
test.equal(timers.count(), 1, 'it waits out the remainder of the interval instead');
|
|
451
|
+
test.ok(timers.pending()[0].delay <= 60000, 'and never longer than a full interval');
|
|
452
|
+
|
|
453
|
+
await connection.preCheck();
|
|
454
|
+
await second;
|
|
455
|
+
|
|
456
|
+
// Once a full interval has elapsed, a fresh session polls at once again
|
|
457
|
+
connection._lastPollAt = Date.now() - 61000;
|
|
458
|
+
let third = idleCommand(connection, 60000);
|
|
459
|
+
await timers.drain();
|
|
460
|
+
test.deepEqual(connection.commands, ['NOOP', 'NOOP'], 'a session starting after the interval polls right away');
|
|
461
|
+
|
|
462
|
+
await connection.preCheck();
|
|
463
|
+
await third;
|
|
464
|
+
test.done();
|
|
465
|
+
});
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
module.exports['Polling: a failed poll does not defer the next session'] = async test => {
|
|
469
|
+
await withFakeTimers(async timers => {
|
|
470
|
+
let failNext = true;
|
|
471
|
+
let connection = createConnection({
|
|
472
|
+
exec: async command => {
|
|
473
|
+
connection.commands.push(command);
|
|
474
|
+
if (failNext) {
|
|
475
|
+
failNext = false;
|
|
476
|
+
throw new Error('poll failed');
|
|
477
|
+
}
|
|
478
|
+
return { next: () => {} };
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
// The failing poll cancels its own session. The attempt checked nothing, so it must not
|
|
483
|
+
// count as a poll: only a completed poll moves the schedule stamp forward.
|
|
484
|
+
let first = idleCommand(connection, 60000);
|
|
485
|
+
await timers.drain();
|
|
486
|
+
await first;
|
|
487
|
+
test.deepEqual(connection.commands, ['NOOP'], 'the first poll ran and failed');
|
|
488
|
+
|
|
489
|
+
let second = idleCommand(connection, 60000);
|
|
490
|
+
await timers.drain();
|
|
491
|
+
test.deepEqual(connection.commands, ['NOOP', 'NOOP'], 'the next session retries immediately instead of waiting out an interval');
|
|
492
|
+
|
|
493
|
+
await connection.preCheck();
|
|
494
|
+
await second;
|
|
495
|
+
test.done();
|
|
496
|
+
});
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
module.exports['Polling: a backward clock step never defers the next poll past one interval'] = async test => {
|
|
500
|
+
await withFakeTimers(async timers => {
|
|
501
|
+
let connection = createConnection();
|
|
502
|
+
|
|
503
|
+
// A last-poll stamp in the future is what an NTP step or a VM clock sync leaves behind.
|
|
504
|
+
// Without the clamp the remainder math would schedule the next poll a full clock jump
|
|
505
|
+
// plus one interval away.
|
|
506
|
+
connection._lastPollAt = Date.now() + 60 * 60 * 1000;
|
|
507
|
+
|
|
508
|
+
let idlePromise = idleCommand(connection, 60000);
|
|
509
|
+
await timers.drain();
|
|
510
|
+
test.deepEqual(connection.commands, [], 'no immediate poll - the schedule is resumed');
|
|
511
|
+
test.equal(timers.count(), 1, 'a poll timer is armed');
|
|
512
|
+
test.ok(timers.pending()[0].delay <= 60000, 'and it is never more than one interval away');
|
|
513
|
+
|
|
514
|
+
await connection.preCheck();
|
|
515
|
+
await idlePromise;
|
|
516
|
+
test.done();
|
|
517
|
+
});
|
|
518
|
+
};
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// reader/handler error branches.
|
|
6
6
|
|
|
7
7
|
const { ImapFlow } = require('../lib/imap-flow');
|
|
8
|
+
const { makeSocketStub } = require('./fixtures/test-client');
|
|
8
9
|
|
|
9
10
|
const makeClient = (overrides = {}) => {
|
|
10
11
|
let client = new ImapFlow({
|
|
@@ -293,15 +294,6 @@ module.exports['Coverage: authenticate throws when run yields falsy auth result'
|
|
|
293
294
|
// Socket event handlers (built by setSocketHandlers)
|
|
294
295
|
// ============================================================================
|
|
295
296
|
|
|
296
|
-
// Minimal socket stub that records listeners so handlers can be invoked directly.
|
|
297
|
-
const makeSocketStub = () => ({
|
|
298
|
-
destroyed: false,
|
|
299
|
-
once() {},
|
|
300
|
-
on() {},
|
|
301
|
-
removeListener() {},
|
|
302
|
-
destroy() {}
|
|
303
|
-
});
|
|
304
|
-
|
|
305
297
|
module.exports['Coverage: setSocketHandlers removes a lingering connect error handler'] = test => {
|
|
306
298
|
let client = makeClient();
|
|
307
299
|
let removed = null;
|
|
@@ -389,7 +381,9 @@ module.exports['Coverage: _socketTimeout recovers an IDLE connection with NOOP']
|
|
|
389
381
|
await drain();
|
|
390
382
|
await drain();
|
|
391
383
|
test.ok(noopRun, 'NOOP issued to recover IDLE');
|
|
392
|
-
|
|
384
|
+
// Restarting IDLE is autoidle()'s decision once the NOOP settles (run() re-arms it);
|
|
385
|
+
// the watchdog handler itself must not bypass the busy guard by calling idle() directly.
|
|
386
|
+
test.equal(idleResumed, false, 'the handler does not restart IDLE by itself');
|
|
393
387
|
test.done();
|
|
394
388
|
};
|
|
395
389
|
|
|
@@ -9,6 +9,7 @@ const libbase64 = require('libbase64');
|
|
|
9
9
|
const libqp = require('libqp');
|
|
10
10
|
const libmime = require('libmime');
|
|
11
11
|
const { Writable, finished } = require('stream');
|
|
12
|
+
const { chunkedFetchOne } = require('./fixtures/test-client');
|
|
12
13
|
|
|
13
14
|
const makeClient = (overrides = {}) => {
|
|
14
15
|
let client = new ImapFlow({
|
|
@@ -217,11 +218,7 @@ module.exports['Download: returns empty object without mailbox'] = async test =>
|
|
|
217
218
|
module.exports['Download: full message in multiple chunks'] = async test => {
|
|
218
219
|
let client = makeClient();
|
|
219
220
|
let body = Buffer.from('A'.repeat(10));
|
|
220
|
-
client.fetchOne =
|
|
221
|
-
let start = query.source.start;
|
|
222
|
-
let maxLength = query.source.maxLength;
|
|
223
|
-
return { uid: 1, size: body.length, source: body.slice(start, start + maxLength) };
|
|
224
|
-
};
|
|
221
|
+
client.fetchOne = chunkedFetchOne(body);
|
|
225
222
|
let { meta, content } = await client.download('1', false, { chunkSize: 4 });
|
|
226
223
|
test.equal(meta.contentType, 'message/rfc822');
|
|
227
224
|
test.equal(meta.expectedSize, 10);
|
|
@@ -233,11 +230,7 @@ module.exports['Download: full message in multiple chunks'] = async test => {
|
|
|
233
230
|
module.exports['Download: falls back to default chunkSize/maxBytes when zero'] = async test => {
|
|
234
231
|
let client = makeClient();
|
|
235
232
|
let body = Buffer.from('tiny');
|
|
236
|
-
client.fetchOne =
|
|
237
|
-
let start = query.source.start;
|
|
238
|
-
let maxLength = query.source.maxLength;
|
|
239
|
-
return { uid: 1, size: body.length, source: body.slice(start, start + maxLength) };
|
|
240
|
-
};
|
|
233
|
+
client.fetchOne = chunkedFetchOne(body);
|
|
241
234
|
// zero values are falsy -> the (|| default) fallbacks kick in
|
|
242
235
|
let { content } = await client.download('1', false, { chunkSize: 0, maxBytes: 0 });
|
|
243
236
|
let data = await collect(content);
|
|
@@ -6,17 +6,8 @@
|
|
|
6
6
|
// and the untaggedFetch flag/modseq branches.
|
|
7
7
|
|
|
8
8
|
const { ImapFlow } = require('../lib/imap-flow');
|
|
9
|
-
|
|
10
|
-
const makeClient
|
|
11
|
-
let client = new ImapFlow({
|
|
12
|
-
host: 'imap.example.com',
|
|
13
|
-
port: 993,
|
|
14
|
-
auth: { user: 'test', pass: 'test' },
|
|
15
|
-
logger: false,
|
|
16
|
-
...overrides
|
|
17
|
-
});
|
|
18
|
-
return client;
|
|
19
|
-
};
|
|
9
|
+
const { withFakeTimers } = require('./fixtures/fake-timers');
|
|
10
|
+
const { makeClient, makeIdleReadyClient } = require('./fixtures/test-client');
|
|
20
11
|
|
|
21
12
|
// ============================================================================
|
|
22
13
|
// emitError
|
|
@@ -275,29 +266,20 @@ module.exports['Internals: autoidle does nothing when not selected'] = test => {
|
|
|
275
266
|
test.done();
|
|
276
267
|
};
|
|
277
268
|
|
|
278
|
-
module.exports['Internals: autoidle schedules idle when selected'] = test => {
|
|
279
|
-
|
|
280
|
-
|
|
269
|
+
module.exports['Internals: autoidle schedules idle when selected'] = async test => {
|
|
270
|
+
await withFakeTimers(async timers => {
|
|
271
|
+
let client = makeIdleReadyClient();
|
|
272
|
+
|
|
273
|
+
let idleCalled = false;
|
|
274
|
+
client.idle = async () => {
|
|
275
|
+
idleCalled = true;
|
|
276
|
+
};
|
|
281
277
|
|
|
282
|
-
let realSetTimeout = global.setTimeout;
|
|
283
|
-
let idleCalled = false;
|
|
284
|
-
client.idle = async () => {
|
|
285
|
-
idleCalled = true;
|
|
286
|
-
};
|
|
287
|
-
// Intercept the 15s idle timer and fire it synchronously
|
|
288
|
-
global.setTimeout = (fn, ms) => {
|
|
289
|
-
if (ms === 15 * 1000) {
|
|
290
|
-
fn();
|
|
291
|
-
return { unref() {} };
|
|
292
|
-
}
|
|
293
|
-
return realSetTimeout(fn, ms);
|
|
294
|
-
};
|
|
295
|
-
try {
|
|
296
278
|
client.autoidle();
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
279
|
+
await timers.fire();
|
|
280
|
+
|
|
281
|
+
test.ok(idleCalled);
|
|
282
|
+
});
|
|
301
283
|
test.done();
|
|
302
284
|
};
|
|
303
285
|
|
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
// Asserted through timer identity and cleanup rather than wall-clock sleeps.
|
|
10
10
|
|
|
11
11
|
const net = require('net');
|
|
12
|
-
const { ImapFlow } = require('../lib/imap-flow');
|
|
13
12
|
const idleCommand = require('../lib/commands/idle.js');
|
|
14
13
|
const { withFakeTimers } = require('./fixtures/fake-timers');
|
|
14
|
+
const { makeClient, makeIdleReadyClient } = require('./fixtures/test-client');
|
|
15
15
|
|
|
16
16
|
const CAPS = 'IMAP4rev1 ID ENABLE NAMESPACE IDLE';
|
|
17
17
|
|
|
@@ -52,15 +52,6 @@ const createServer = () =>
|
|
|
52
52
|
|
|
53
53
|
const listen = server => new Promise(resolve => server.listen(0, '127.0.0.1', () => resolve(server.address().port)));
|
|
54
54
|
|
|
55
|
-
const makeClient = (overrides = {}) =>
|
|
56
|
-
new ImapFlow({
|
|
57
|
-
host: '127.0.0.1',
|
|
58
|
-
port: 993,
|
|
59
|
-
logger: false,
|
|
60
|
-
auth: { user: 'test', pass: 'secret' },
|
|
61
|
-
...overrides
|
|
62
|
-
});
|
|
63
|
-
|
|
64
55
|
module.exports['Timers: connection and greeting deadlines keep the process alive'] = async test => {
|
|
65
56
|
let server = createServer();
|
|
66
57
|
let port = await listen(server);
|
|
@@ -99,15 +90,13 @@ module.exports['Timers: connection and greeting deadlines keep the process alive
|
|
|
99
90
|
|
|
100
91
|
module.exports['Timers: the auto-IDLE timer is unrefd and cleared on close'] = async test => {
|
|
101
92
|
await withFakeTimers(async timers => {
|
|
102
|
-
let client =
|
|
103
|
-
client.state = client.states.SELECTED;
|
|
104
|
-
client.idle = async () => {};
|
|
93
|
+
let client = makeIdleReadyClient();
|
|
105
94
|
|
|
106
95
|
client.autoidle();
|
|
107
96
|
|
|
108
97
|
let armed = timers.pending();
|
|
109
98
|
test.equal(armed.length, 1, 'exactly one auto-IDLE timer is armed');
|
|
110
|
-
test.equal(armed[0].delay,
|
|
99
|
+
test.equal(armed[0].delay, client.autoIdleDelay);
|
|
111
100
|
test.ok(armed[0].unrefd, 'the background auto-IDLE timer does not keep the process alive');
|
|
112
101
|
|
|
113
102
|
client.close();
|
|
@@ -119,9 +108,7 @@ module.exports['Timers: the auto-IDLE timer is unrefd and cleared on close'] = a
|
|
|
119
108
|
|
|
120
109
|
module.exports['Timers: a restarted auto-IDLE timer replaces the previous one'] = async test => {
|
|
121
110
|
await withFakeTimers(async timers => {
|
|
122
|
-
let client =
|
|
123
|
-
client.state = client.states.SELECTED;
|
|
124
|
-
client.idle = async () => {};
|
|
111
|
+
let client = makeIdleReadyClient();
|
|
125
112
|
|
|
126
113
|
client.autoidle();
|
|
127
114
|
client.autoidle();
|