@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 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 (default 5 min). Distinct
44
- // from inactivityTimeout — that one resets on each data chunk and
45
- // can be deferred indefinitely by a slow-trickling server.
46
- this.commandWallClockTimeout = config.commandWallClockTimeout ?? 300_000;
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
- // HEARTBEAT_INTERVAL_MS periodic progress log while a command
805
- // is pending. Catches wedges in flight rather than only at the
806
- // 300s timeout edge. Key signal: if `sinceLastRead` keeps
807
- // climbing, the socket is silently dead. If reads are happening
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.commandTimer = null;
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
- // Two timers, two responsibilities:
835
- // commandTimer = "no bytes for N seconds" — handleData resets it
836
- // on every chunk so a steadily-streaming FETCH
837
- // doesn't get killed mid-flight.
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
- const COMMAND_WALL_CLOCK_TIMEOUT_MS = this.commandWallClockTimeout;
848
- let wallClockTimer = setTimeout(() => onTimeout(`IMAP command wall-clock timeout (${COMMAND_WALL_CLOCK_TIMEOUT_MS / 1000}s)`), COMMAND_WALL_CLOCK_TIMEOUT_MS);
849
- const clearTimers = () => {
850
- if (this.commandTimer) {
851
- clearTimeout(this.commandTimer);
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) => { clearTimers(); resolve(responses); },
866
- reject: (err) => { clearTimers(); 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
- clearTimers();
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
- clearTimers();
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 data is flowing, connection is alive
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/iflow-direct",
3
- "version": "0.1.34",
3
+ "version": "0.1.35",
4
4
  "description": "Direct IMAP client — transport-agnostic, no Node.js dependencies, browser-ready",
5
5
  "main": "index.js",
6
6
  "types": "index.ts",