@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 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 (default 5 min). Distinct
36
- // from inactivityTimeout — that one resets on each data chunk and
37
- // can be deferred indefinitely by a slow-trickling server.
38
- 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;
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
- // HEARTBEAT_INTERVAL_MS periodic progress log while a command
797
- // is pending. Catches wedges in flight rather than only at the
798
- // 300s timeout edge. Key signal: if `sinceLastRead` keeps
799
- // climbing, the socket is silently dead. If reads are happening
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.commandTimer = null;
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
- // Two timers, two responsibilities:
827
- // commandTimer = "no bytes for N seconds" — handleData resets it
828
- // on every chunk so a steadily-streaming FETCH
829
- // doesn't get killed mid-flight.
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
- const COMMAND_WALL_CLOCK_TIMEOUT_MS = this.commandWallClockTimeout;
840
- let wallClockTimer = setTimeout(() => onTimeout(`IMAP command wall-clock timeout (${COMMAND_WALL_CLOCK_TIMEOUT_MS / 1000}s)`), COMMAND_WALL_CLOCK_TIMEOUT_MS);
841
- const clearTimers = () => {
842
- if (this.commandTimer) {
843
- clearTimeout(this.commandTimer);
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) => { clearTimers(); resolve(responses); },
858
- reject: (err) => { clearTimers(); 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
- clearTimers();
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
- clearTimers();
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 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.
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
- const encoded = new TextEncoder().encode(this.buffer);
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
- // if (neededBytes !== literal.length) {
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
- const lineEnd = this.buffer.indexOf("\r\n");
970
- if (lineEnd < 0)
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
- const line = this.buffer.substring(0, lineEnd + 2);
973
- this.buffer = this.buffer.substring(lineEnd + 2);
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/iflow-direct",
3
- "version": "0.1.33",
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",