@bobfrankston/iflow-direct 0.1.33 → 0.1.35
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/imap-native.d.ts +26 -0
- package/imap-native.js +114 -67
- package/package.json +1 -1
package/imap-native.d.ts
CHANGED
|
@@ -48,7 +48,15 @@ export declare class NativeImapClient {
|
|
|
48
48
|
private transport;
|
|
49
49
|
private transportFactory;
|
|
50
50
|
private config;
|
|
51
|
+
/** Receive buffer + read offset. CRITICAL: never reassign with
|
|
52
|
+
* `this.buffer = this.buffer.substring(N)` — that copies the whole
|
|
53
|
+
* remainder per line and is O(n²) over a multi-line response (a 457KB
|
|
54
|
+
* LIST response actually blocked the daemon's event loop for 10 minutes
|
|
55
|
+
* in production). Use bufferOffset to advance the read cursor; only
|
|
56
|
+
* compact the buffer string when the consumed portion is >= half its
|
|
57
|
+
* length, keeping per-line work O(1). */
|
|
51
58
|
private buffer;
|
|
59
|
+
private bufferOffset;
|
|
52
60
|
private pendingCommand;
|
|
53
61
|
private capabilities;
|
|
54
62
|
private _connected;
|
|
@@ -63,6 +71,13 @@ export declare class NativeImapClient {
|
|
|
63
71
|
private greetingResolve;
|
|
64
72
|
/** Callback for waitForContinuation — set when waiting for "+" response */
|
|
65
73
|
private continuationResolve;
|
|
74
|
+
/** Per-command heartbeat (30s). Hoisted to instance state so any path
|
|
75
|
+
* that swaps pendingCommand can clear it cleanly — closure-scoped
|
|
76
|
+
* timers leaked when handleData's inline replacement or IDLE
|
|
77
|
+
* suspend/resume bypassed sendCommandCore's resolve/reject closures. */
|
|
78
|
+
private heartbeatTimer;
|
|
79
|
+
/** Hard wall-clock cap per command. Same hoist for the same reason. */
|
|
80
|
+
private wallClockTimer;
|
|
66
81
|
constructor(config: ImapClientConfig, transportFactory: TransportFactory);
|
|
67
82
|
get connected(): boolean;
|
|
68
83
|
/** Check the underlying transport's connected state — catches silently dead sockets. */
|
|
@@ -203,6 +218,17 @@ export declare class NativeImapClient {
|
|
|
203
218
|
private waitForContinuation;
|
|
204
219
|
private waitForTagged;
|
|
205
220
|
private handleData;
|
|
221
|
+
/** Single canonical "command finished or abandoned" cleanup. Every
|
|
222
|
+
* path that ends a command's lifecycle — resolve, reject, timeout,
|
|
223
|
+
* socket close, command-replace — calls this. Was a leak source:
|
|
224
|
+
* closure-scoped timers in sendCommandCore couldn't be cleared from
|
|
225
|
+
* handleData's inline-replacement timer or from IDLE suspend/resume. */
|
|
226
|
+
private clearAllCommandTimers;
|
|
227
|
+
/** Compact the buffer when bufferOffset has advanced past half. Cheap
|
|
228
|
+
* amortized cost; eliminates the O(n²) substring-each-line antipattern
|
|
229
|
+
* that synchronously blocked the daemon for 10 minutes on a large LIST
|
|
230
|
+
* response. */
|
|
231
|
+
private compactBufferIfNeeded;
|
|
206
232
|
private processBuffer;
|
|
207
233
|
private handleUntaggedResponse;
|
|
208
234
|
private parseFetchResponses;
|
package/imap-native.js
CHANGED
|
@@ -11,7 +11,15 @@ export class NativeImapClient {
|
|
|
11
11
|
transport;
|
|
12
12
|
transportFactory;
|
|
13
13
|
config;
|
|
14
|
+
/** Receive buffer + read offset. CRITICAL: never reassign with
|
|
15
|
+
* `this.buffer = this.buffer.substring(N)` — that copies the whole
|
|
16
|
+
* remainder per line and is O(n²) over a multi-line response (a 457KB
|
|
17
|
+
* LIST response actually blocked the daemon's event loop for 10 minutes
|
|
18
|
+
* in production). Use bufferOffset to advance the read cursor; only
|
|
19
|
+
* compact the buffer string when the consumed portion is >= half its
|
|
20
|
+
* length, keeping per-line work O(1). */
|
|
14
21
|
buffer = "";
|
|
22
|
+
bufferOffset = 0;
|
|
15
23
|
pendingCommand = null;
|
|
16
24
|
capabilities = new Set();
|
|
17
25
|
_connected = false;
|
|
@@ -26,16 +34,29 @@ export class NativeImapClient {
|
|
|
26
34
|
greetingResolve = null;
|
|
27
35
|
/** Callback for waitForContinuation — set when waiting for "+" response */
|
|
28
36
|
continuationResolve = null;
|
|
37
|
+
/** Per-command heartbeat (30s). Hoisted to instance state so any path
|
|
38
|
+
* that swaps pendingCommand can clear it cleanly — closure-scoped
|
|
39
|
+
* timers leaked when handleData's inline replacement or IDLE
|
|
40
|
+
* suspend/resume bypassed sendCommandCore's resolve/reject closures. */
|
|
41
|
+
heartbeatTimer = null;
|
|
42
|
+
/** Hard wall-clock cap per command. Same hoist for the same reason. */
|
|
43
|
+
wallClockTimer = null;
|
|
29
44
|
constructor(config, transportFactory) {
|
|
30
45
|
this.config = config;
|
|
31
46
|
this.transportFactory = transportFactory;
|
|
32
47
|
this.transport = transportFactory();
|
|
33
48
|
this.verbose = config.verbose || false;
|
|
34
49
|
this.inactivityTimeout = config.inactivityTimeout ?? 60000;
|
|
35
|
-
// Hard wall-clock deadline per command
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
|
|
50
|
+
// Hard wall-clock deadline per command. Distinct from inactivityTimeout
|
|
51
|
+
// — that one resets on each data chunk and can be deferred indefinitely
|
|
52
|
+
// by a slow-trickling server. Default 90s: in production we see
|
|
53
|
+
// connections silently die with sinceLastRead climbing for 50s+ on
|
|
54
|
+
// FETCH; 5 min was leaving them wedged for too long, blocking sync /
|
|
55
|
+
// prefetch / new-mail behind a dead socket. 90s lets a slow but
|
|
56
|
+
// genuine FETCH complete (Sent in isolation: 26ms; even a 50-message
|
|
57
|
+
// FETCH was <1s) while killing dead sockets aggressively. Override
|
|
58
|
+
// via ImapClientConfig.commandWallClockTimeout for known-slow servers.
|
|
59
|
+
this.commandWallClockTimeout = config.commandWallClockTimeout ?? 90_000;
|
|
39
60
|
this.fetchChunkSize = config.fetchChunkSize ?? 25;
|
|
40
61
|
this.fetchChunkSizeMax = config.fetchChunkSizeMax ?? 500;
|
|
41
62
|
this.greetingTimeout = config.greetingTimeout ?? 10000;
|
|
@@ -53,8 +74,13 @@ export class NativeImapClient {
|
|
|
53
74
|
this.transport = this.transportFactory();
|
|
54
75
|
this._connected = false;
|
|
55
76
|
this.buffer = "";
|
|
77
|
+
this.bufferOffset = 0;
|
|
56
78
|
this.pendingCommand = null;
|
|
57
79
|
this.selectedMailbox = null;
|
|
80
|
+
// Old socket is gone — kill any timers still running for the
|
|
81
|
+
// command that was on it. Without this, a stale heartbeat/wall-clock
|
|
82
|
+
// continues firing into a transport that no longer exists.
|
|
83
|
+
this.clearAllCommandTimers();
|
|
58
84
|
}
|
|
59
85
|
// ── Connection ──
|
|
60
86
|
async connect() {
|
|
@@ -70,6 +96,10 @@ export class NativeImapClient {
|
|
|
70
96
|
clearTimeout(this.idleRefreshTimer);
|
|
71
97
|
this.idleRefreshTimer = null;
|
|
72
98
|
}
|
|
99
|
+
// Kill any per-command timers — the connection is gone, no
|
|
100
|
+
// command on it can ever complete. Otherwise heartbeat keeps
|
|
101
|
+
// firing for a connection that no longer exists.
|
|
102
|
+
this.clearAllCommandTimers();
|
|
73
103
|
if (this.pendingCommand) {
|
|
74
104
|
const { reject } = this.pendingCommand;
|
|
75
105
|
this.pendingCommand = null;
|
|
@@ -793,69 +823,33 @@ export class NativeImapClient {
|
|
|
793
823
|
return "";
|
|
794
824
|
}
|
|
795
825
|
};
|
|
796
|
-
//
|
|
797
|
-
//
|
|
798
|
-
//
|
|
799
|
-
|
|
800
|
-
// but the tagged OK never arrives, the parser is stuck.
|
|
801
|
-
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
826
|
+
// Always start clean — defends against a prior pendingCommand
|
|
827
|
+
// having leaked timers (e.g. swap by suspendIdle/resumeIdle that
|
|
828
|
+
// bypassed resolve/reject).
|
|
829
|
+
this.clearAllCommandTimers();
|
|
802
830
|
const cmdStart = Date.now();
|
|
803
831
|
const cmdSummary = command.split("\r")[0].substring(0, 80);
|
|
804
|
-
let heartbeatTimer = setInterval(() => {
|
|
805
|
-
const waited = Date.now() - cmdStart;
|
|
806
|
-
console.log(` [imap] still waiting for tag ${tag} after ${(waited / 1000).toFixed(1)}s — ${cmdSummary}${transportDiag()}`);
|
|
807
|
-
}, HEARTBEAT_INTERVAL_MS);
|
|
808
832
|
const onTimeout = (kind) => {
|
|
809
|
-
this.
|
|
833
|
+
this.clearAllCommandTimers();
|
|
810
834
|
this.pendingCommand = null;
|
|
811
|
-
if (heartbeatTimer) {
|
|
812
|
-
clearInterval(heartbeatTimer);
|
|
813
|
-
heartbeatTimer = null;
|
|
814
|
-
}
|
|
815
|
-
if (wallClockTimer) {
|
|
816
|
-
clearTimeout(wallClockTimer);
|
|
817
|
-
wallClockTimer = null;
|
|
818
|
-
}
|
|
819
|
-
// Kill the connection — a timed-out connection has stale data in the pipe.
|
|
820
|
-
// Mark disconnected so ensureConnected() reconnects on the next call —
|
|
821
|
-
// transport.close() may or may not fire onClose synchronously.
|
|
822
835
|
this._connected = false;
|
|
823
836
|
this.transport.close?.();
|
|
824
837
|
reject(new Error(`${kind}: ${cmdSummary}${transportDiag()}`));
|
|
825
838
|
};
|
|
826
|
-
//
|
|
827
|
-
// commandTimer
|
|
828
|
-
//
|
|
829
|
-
//
|
|
830
|
-
// wallClockTimer = "this command has been pending too long
|
|
831
|
-
// total" — fires regardless of trickling bytes.
|
|
832
|
-
// Catches the pathological case where a server
|
|
833
|
-
// drips one byte every <inactivityTimeout> seconds
|
|
834
|
-
// for hours (real bobma bug we hit overnight:
|
|
835
|
-
// 3 connections wedged 2-3 hours each because
|
|
836
|
-
// the inactivity reset kept deferring the only
|
|
837
|
-
// timer in play).
|
|
839
|
+
// Three timers, three responsibilities — all instance-scoped now:
|
|
840
|
+
// commandTimer = inactivity (resets on each data chunk)
|
|
841
|
+
// wallClockTimer = hard cap from command start, never resets
|
|
842
|
+
// heartbeatTimer = periodic progress log every 30s
|
|
838
843
|
this.commandTimer = setTimeout(() => onTimeout(`IMAP inactivity timeout (${this.inactivityTimeout / 1000}s)`), this.inactivityTimeout);
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
this.commandTimer = null;
|
|
845
|
-
}
|
|
846
|
-
if (heartbeatTimer) {
|
|
847
|
-
clearInterval(heartbeatTimer);
|
|
848
|
-
heartbeatTimer = null;
|
|
849
|
-
}
|
|
850
|
-
if (wallClockTimer) {
|
|
851
|
-
clearTimeout(wallClockTimer);
|
|
852
|
-
wallClockTimer = null;
|
|
853
|
-
}
|
|
854
|
-
};
|
|
844
|
+
this.wallClockTimer = setTimeout(() => onTimeout(`IMAP command wall-clock timeout (${this.commandWallClockTimeout / 1000}s)`), this.commandWallClockTimeout);
|
|
845
|
+
this.heartbeatTimer = setInterval(() => {
|
|
846
|
+
const waited = Date.now() - cmdStart;
|
|
847
|
+
console.log(` [imap] still waiting for tag ${tag} after ${(waited / 1000).toFixed(1)}s — ${cmdSummary}${transportDiag()}`);
|
|
848
|
+
}, 30_000);
|
|
855
849
|
this.pendingCommand = {
|
|
856
850
|
tag, responses: [],
|
|
857
|
-
resolve: (responses) => {
|
|
858
|
-
reject: (err) => {
|
|
851
|
+
resolve: (responses) => { this.clearAllCommandTimers(); resolve(responses); },
|
|
852
|
+
reject: (err) => { this.clearAllCommandTimers(); reject(err); },
|
|
859
853
|
onUntagged,
|
|
860
854
|
};
|
|
861
855
|
// PRE-WRITE HEALTH CHECK — verify the socket is in a writable
|
|
@@ -865,7 +859,7 @@ export class NativeImapClient {
|
|
|
865
859
|
try {
|
|
866
860
|
const h = this.transport?.socketHealth;
|
|
867
861
|
if (h && (h.destroyed || h.writableEnded || !h.writable)) {
|
|
868
|
-
|
|
862
|
+
this.clearAllCommandTimers();
|
|
869
863
|
this.pendingCommand = null;
|
|
870
864
|
this._connected = false;
|
|
871
865
|
reject(new Error(`IMAP socket dead before write: destroyed=${h.destroyed} writableEnded=${h.writableEnded} writable=${h.writable}${transportDiag()}`));
|
|
@@ -874,7 +868,7 @@ export class NativeImapClient {
|
|
|
874
868
|
}
|
|
875
869
|
catch { /* transport may not expose socketHealth — fall through */ }
|
|
876
870
|
this.transport.write(command).catch((err) => {
|
|
877
|
-
|
|
871
|
+
this.clearAllCommandTimers();
|
|
878
872
|
// Write failure = dead socket. Clear pendingCommand + disconnect flag
|
|
879
873
|
// so the next ensureConnected() does a fresh connect instead of
|
|
880
874
|
// reusing a socket that the OS layer has already declared dead
|
|
@@ -904,7 +898,10 @@ export class NativeImapClient {
|
|
|
904
898
|
});
|
|
905
899
|
}
|
|
906
900
|
handleData(data) {
|
|
907
|
-
// Reset inactivity timer
|
|
901
|
+
// Reset inactivity timer ONLY (heartbeat + wall-clock NEVER reset
|
|
902
|
+
// on data — that was the original bug: wall-clock got reset every
|
|
903
|
+
// byte, so a slow-trickling server's command never died). Inactivity
|
|
904
|
+
// is "no data for N seconds"; data IS arriving, so push it out.
|
|
908
905
|
if (this.commandTimer) {
|
|
909
906
|
clearTimeout(this.commandTimer);
|
|
910
907
|
this.commandTimer = setTimeout(() => {
|
|
@@ -912,6 +909,11 @@ export class NativeImapClient {
|
|
|
912
909
|
if (this.pendingCommand) {
|
|
913
910
|
const cmd = this.pendingCommand;
|
|
914
911
|
this.pendingCommand = null;
|
|
912
|
+
// Use the canonical clear so heartbeat + wall-clock
|
|
913
|
+
// also stop. cmd.reject runs clearAllCommandTimers via
|
|
914
|
+
// the closure binding — call it directly here too in
|
|
915
|
+
// case cmd.reject was already replaced (defense in depth).
|
|
916
|
+
this.clearAllCommandTimers();
|
|
915
917
|
this._connected = false;
|
|
916
918
|
this.transport.close?.();
|
|
917
919
|
cmd.reject(new Error(`IMAP inactivity timeout (${this.inactivityTimeout / 1000}s)`));
|
|
@@ -921,6 +923,35 @@ export class NativeImapClient {
|
|
|
921
923
|
this.buffer += data;
|
|
922
924
|
this.processBuffer();
|
|
923
925
|
}
|
|
926
|
+
/** Single canonical "command finished or abandoned" cleanup. Every
|
|
927
|
+
* path that ends a command's lifecycle — resolve, reject, timeout,
|
|
928
|
+
* socket close, command-replace — calls this. Was a leak source:
|
|
929
|
+
* closure-scoped timers in sendCommandCore couldn't be cleared from
|
|
930
|
+
* handleData's inline-replacement timer or from IDLE suspend/resume. */
|
|
931
|
+
clearAllCommandTimers() {
|
|
932
|
+
if (this.commandTimer) {
|
|
933
|
+
clearTimeout(this.commandTimer);
|
|
934
|
+
this.commandTimer = null;
|
|
935
|
+
}
|
|
936
|
+
if (this.heartbeatTimer) {
|
|
937
|
+
clearInterval(this.heartbeatTimer);
|
|
938
|
+
this.heartbeatTimer = null;
|
|
939
|
+
}
|
|
940
|
+
if (this.wallClockTimer) {
|
|
941
|
+
clearTimeout(this.wallClockTimer);
|
|
942
|
+
this.wallClockTimer = null;
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
/** Compact the buffer when bufferOffset has advanced past half. Cheap
|
|
946
|
+
* amortized cost; eliminates the O(n²) substring-each-line antipattern
|
|
947
|
+
* that synchronously blocked the daemon for 10 minutes on a large LIST
|
|
948
|
+
* response. */
|
|
949
|
+
compactBufferIfNeeded() {
|
|
950
|
+
if (this.bufferOffset > 0 && this.bufferOffset >= this.buffer.length / 2) {
|
|
951
|
+
this.buffer = this.buffer.substring(this.bufferOffset);
|
|
952
|
+
this.bufferOffset = 0;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
924
955
|
processBuffer() {
|
|
925
956
|
while (true) {
|
|
926
957
|
// Check for literal {size}\r\n — reading exact BYTE count of literal data
|
|
@@ -929,16 +960,20 @@ export class NativeImapClient {
|
|
|
929
960
|
// We must use byte-accurate extraction (TextEncoder for browser, Buffer for Node).
|
|
930
961
|
if (this.pendingCommand?.literalBytes != null) {
|
|
931
962
|
const neededBytes = this.pendingCommand.literalBytes;
|
|
932
|
-
|
|
963
|
+
// Encode only the unread portion — was encoding the whole
|
|
964
|
+
// buffer (including already-consumed bytes) on every literal,
|
|
965
|
+
// which on a multi-MB buffer is a non-trivial chunk of work.
|
|
966
|
+
const unread = this.bufferOffset === 0 ? this.buffer : this.buffer.substring(this.bufferOffset);
|
|
967
|
+
const encoded = new TextEncoder().encode(unread);
|
|
933
968
|
const bufferBytes = encoded.byteLength;
|
|
934
969
|
if (bufferBytes >= neededBytes) {
|
|
935
970
|
const literal_bytes = encoded.subarray(0, neededBytes);
|
|
936
971
|
const rest_bytes = encoded.subarray(neededBytes);
|
|
937
972
|
let literal = new TextDecoder().decode(literal_bytes);
|
|
973
|
+
// Reset buffer to just the rest — bufferOffset goes back
|
|
974
|
+
// to 0 since we replaced the buffer content.
|
|
938
975
|
this.buffer = new TextDecoder().decode(rest_bytes);
|
|
939
|
-
|
|
940
|
-
// console.log(`iflow-direct: [imap] literal: ${neededBytes} bytes → ${literal.length} chars (multi-byte corrected)`);
|
|
941
|
-
// }
|
|
976
|
+
this.bufferOffset = 0;
|
|
942
977
|
// For non-BODY literals (e.g. display names in ENVELOPE), wrap in quotes
|
|
943
978
|
// so tokenizeParenList treats them as a single token
|
|
944
979
|
if (!this.pendingCommand.currentLiteralKey) {
|
|
@@ -966,11 +1001,23 @@ export class NativeImapClient {
|
|
|
966
1001
|
}
|
|
967
1002
|
break; // Wait for more data
|
|
968
1003
|
}
|
|
969
|
-
|
|
970
|
-
|
|
1004
|
+
// Find next CRLF starting from the read offset (NOT from 0 —
|
|
1005
|
+
// the bytes before bufferOffset are already consumed and
|
|
1006
|
+
// searching them is dead work that scales with response size).
|
|
1007
|
+
const lineEnd = this.buffer.indexOf("\r\n", this.bufferOffset);
|
|
1008
|
+
if (lineEnd < 0) {
|
|
1009
|
+
// No complete line yet. Compact the buffer if we've advanced
|
|
1010
|
+
// far enough that the unconsumed-prefix is dragging.
|
|
1011
|
+
this.compactBufferIfNeeded();
|
|
971
1012
|
break;
|
|
972
|
-
|
|
973
|
-
|
|
1013
|
+
}
|
|
1014
|
+
// Extract the line and advance the read cursor — no substring
|
|
1015
|
+
// copy of the remainder, no buffer reallocation.
|
|
1016
|
+
const line = this.buffer.substring(this.bufferOffset, lineEnd + 2);
|
|
1017
|
+
this.bufferOffset = lineEnd + 2;
|
|
1018
|
+
// Periodic compaction so the consumed prefix doesn't keep
|
|
1019
|
+
// growing forever in long-running connections (IDLE, big sync).
|
|
1020
|
+
this.compactBufferIfNeeded();
|
|
974
1021
|
// Check for literal announcement {size}\r\n at end of line
|
|
975
1022
|
const literalMatch = line.match(/\{(\d+)\}\r\n$/);
|
|
976
1023
|
if (literalMatch && this.pendingCommand) {
|