@bobfrankston/iflow-direct 0.1.34 → 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 +13 -0
- package/imap-native.js +72 -59
- package/package.json +1 -1
package/imap-native.d.ts
CHANGED
|
@@ -71,6 +71,13 @@ export declare class NativeImapClient {
|
|
|
71
71
|
private greetingResolve;
|
|
72
72
|
/** Callback for waitForContinuation — set when waiting for "+" response */
|
|
73
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;
|
|
74
81
|
constructor(config: ImapClientConfig, transportFactory: TransportFactory);
|
|
75
82
|
get connected(): boolean;
|
|
76
83
|
/** Check the underlying transport's connected state — catches silently dead sockets. */
|
|
@@ -211,6 +218,12 @@ export declare class NativeImapClient {
|
|
|
211
218
|
private waitForContinuation;
|
|
212
219
|
private waitForTagged;
|
|
213
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;
|
|
214
227
|
/** Compact the buffer when bufferOffset has advanced past half. Cheap
|
|
215
228
|
* amortized cost; eliminates the O(n²) substring-each-line antipattern
|
|
216
229
|
* that synchronously blocked the daemon for 10 minutes on a large LIST
|
package/imap-native.js
CHANGED
|
@@ -34,16 +34,29 @@ export class NativeImapClient {
|
|
|
34
34
|
greetingResolve = null;
|
|
35
35
|
/** Callback for waitForContinuation — set when waiting for "+" response */
|
|
36
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;
|
|
37
44
|
constructor(config, transportFactory) {
|
|
38
45
|
this.config = config;
|
|
39
46
|
this.transportFactory = transportFactory;
|
|
40
47
|
this.transport = transportFactory();
|
|
41
48
|
this.verbose = config.verbose || false;
|
|
42
49
|
this.inactivityTimeout = config.inactivityTimeout ?? 60000;
|
|
43
|
-
// Hard wall-clock deadline per command
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
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;
|
|
47
60
|
this.fetchChunkSize = config.fetchChunkSize ?? 25;
|
|
48
61
|
this.fetchChunkSizeMax = config.fetchChunkSizeMax ?? 500;
|
|
49
62
|
this.greetingTimeout = config.greetingTimeout ?? 10000;
|
|
@@ -61,8 +74,13 @@ export class NativeImapClient {
|
|
|
61
74
|
this.transport = this.transportFactory();
|
|
62
75
|
this._connected = false;
|
|
63
76
|
this.buffer = "";
|
|
77
|
+
this.bufferOffset = 0;
|
|
64
78
|
this.pendingCommand = null;
|
|
65
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();
|
|
66
84
|
}
|
|
67
85
|
// ── Connection ──
|
|
68
86
|
async connect() {
|
|
@@ -78,6 +96,10 @@ export class NativeImapClient {
|
|
|
78
96
|
clearTimeout(this.idleRefreshTimer);
|
|
79
97
|
this.idleRefreshTimer = null;
|
|
80
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();
|
|
81
103
|
if (this.pendingCommand) {
|
|
82
104
|
const { reject } = this.pendingCommand;
|
|
83
105
|
this.pendingCommand = null;
|
|
@@ -801,69 +823,33 @@ export class NativeImapClient {
|
|
|
801
823
|
return "";
|
|
802
824
|
}
|
|
803
825
|
};
|
|
804
|
-
//
|
|
805
|
-
//
|
|
806
|
-
//
|
|
807
|
-
|
|
808
|
-
// but the tagged OK never arrives, the parser is stuck.
|
|
809
|
-
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();
|
|
810
830
|
const cmdStart = Date.now();
|
|
811
831
|
const cmdSummary = command.split("\r")[0].substring(0, 80);
|
|
812
|
-
let heartbeatTimer = setInterval(() => {
|
|
813
|
-
const waited = Date.now() - cmdStart;
|
|
814
|
-
console.log(` [imap] still waiting for tag ${tag} after ${(waited / 1000).toFixed(1)}s — ${cmdSummary}${transportDiag()}`);
|
|
815
|
-
}, HEARTBEAT_INTERVAL_MS);
|
|
816
832
|
const onTimeout = (kind) => {
|
|
817
|
-
this.
|
|
833
|
+
this.clearAllCommandTimers();
|
|
818
834
|
this.pendingCommand = null;
|
|
819
|
-
if (heartbeatTimer) {
|
|
820
|
-
clearInterval(heartbeatTimer);
|
|
821
|
-
heartbeatTimer = null;
|
|
822
|
-
}
|
|
823
|
-
if (wallClockTimer) {
|
|
824
|
-
clearTimeout(wallClockTimer);
|
|
825
|
-
wallClockTimer = null;
|
|
826
|
-
}
|
|
827
|
-
// Kill the connection — a timed-out connection has stale data in the pipe.
|
|
828
|
-
// Mark disconnected so ensureConnected() reconnects on the next call —
|
|
829
|
-
// transport.close() may or may not fire onClose synchronously.
|
|
830
835
|
this._connected = false;
|
|
831
836
|
this.transport.close?.();
|
|
832
837
|
reject(new Error(`${kind}: ${cmdSummary}${transportDiag()}`));
|
|
833
838
|
};
|
|
834
|
-
//
|
|
835
|
-
// commandTimer
|
|
836
|
-
//
|
|
837
|
-
//
|
|
838
|
-
// wallClockTimer = "this command has been pending too long
|
|
839
|
-
// total" — fires regardless of trickling bytes.
|
|
840
|
-
// Catches the pathological case where a server
|
|
841
|
-
// drips one byte every <inactivityTimeout> seconds
|
|
842
|
-
// for hours (real bobma bug we hit overnight:
|
|
843
|
-
// 3 connections wedged 2-3 hours each because
|
|
844
|
-
// the inactivity reset kept deferring the only
|
|
845
|
-
// 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
|
|
846
843
|
this.commandTimer = setTimeout(() => onTimeout(`IMAP inactivity timeout (${this.inactivityTimeout / 1000}s)`), this.inactivityTimeout);
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
this.commandTimer = null;
|
|
853
|
-
}
|
|
854
|
-
if (heartbeatTimer) {
|
|
855
|
-
clearInterval(heartbeatTimer);
|
|
856
|
-
heartbeatTimer = null;
|
|
857
|
-
}
|
|
858
|
-
if (wallClockTimer) {
|
|
859
|
-
clearTimeout(wallClockTimer);
|
|
860
|
-
wallClockTimer = null;
|
|
861
|
-
}
|
|
862
|
-
};
|
|
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);
|
|
863
849
|
this.pendingCommand = {
|
|
864
850
|
tag, responses: [],
|
|
865
|
-
resolve: (responses) => {
|
|
866
|
-
reject: (err) => {
|
|
851
|
+
resolve: (responses) => { this.clearAllCommandTimers(); resolve(responses); },
|
|
852
|
+
reject: (err) => { this.clearAllCommandTimers(); reject(err); },
|
|
867
853
|
onUntagged,
|
|
868
854
|
};
|
|
869
855
|
// PRE-WRITE HEALTH CHECK — verify the socket is in a writable
|
|
@@ -873,7 +859,7 @@ export class NativeImapClient {
|
|
|
873
859
|
try {
|
|
874
860
|
const h = this.transport?.socketHealth;
|
|
875
861
|
if (h && (h.destroyed || h.writableEnded || !h.writable)) {
|
|
876
|
-
|
|
862
|
+
this.clearAllCommandTimers();
|
|
877
863
|
this.pendingCommand = null;
|
|
878
864
|
this._connected = false;
|
|
879
865
|
reject(new Error(`IMAP socket dead before write: destroyed=${h.destroyed} writableEnded=${h.writableEnded} writable=${h.writable}${transportDiag()}`));
|
|
@@ -882,7 +868,7 @@ export class NativeImapClient {
|
|
|
882
868
|
}
|
|
883
869
|
catch { /* transport may not expose socketHealth — fall through */ }
|
|
884
870
|
this.transport.write(command).catch((err) => {
|
|
885
|
-
|
|
871
|
+
this.clearAllCommandTimers();
|
|
886
872
|
// Write failure = dead socket. Clear pendingCommand + disconnect flag
|
|
887
873
|
// so the next ensureConnected() does a fresh connect instead of
|
|
888
874
|
// reusing a socket that the OS layer has already declared dead
|
|
@@ -912,7 +898,10 @@ export class NativeImapClient {
|
|
|
912
898
|
});
|
|
913
899
|
}
|
|
914
900
|
handleData(data) {
|
|
915
|
-
// 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.
|
|
916
905
|
if (this.commandTimer) {
|
|
917
906
|
clearTimeout(this.commandTimer);
|
|
918
907
|
this.commandTimer = setTimeout(() => {
|
|
@@ -920,6 +909,11 @@ export class NativeImapClient {
|
|
|
920
909
|
if (this.pendingCommand) {
|
|
921
910
|
const cmd = this.pendingCommand;
|
|
922
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();
|
|
923
917
|
this._connected = false;
|
|
924
918
|
this.transport.close?.();
|
|
925
919
|
cmd.reject(new Error(`IMAP inactivity timeout (${this.inactivityTimeout / 1000}s)`));
|
|
@@ -929,6 +923,25 @@ export class NativeImapClient {
|
|
|
929
923
|
this.buffer += data;
|
|
930
924
|
this.processBuffer();
|
|
931
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
|
+
}
|
|
932
945
|
/** Compact the buffer when bufferOffset has advanced past half. Cheap
|
|
933
946
|
* amortized cost; eliminates the O(n²) substring-each-line antipattern
|
|
934
947
|
* that synchronously blocked the daemon for 10 minutes on a large LIST
|