@bobfrankston/iflow-direct 0.1.32 → 0.1.34

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;
@@ -173,6 +181,11 @@ export declare class NativeImapClient {
173
181
  * 60s accommodates Gmail which is slow on SEARCH for large folders.
174
182
  * Overridable via ImapClientConfig.inactivityTimeout — slow Dovecot servers need 180s+. */
175
183
  private inactivityTimeout;
184
+ /** Hard wall-clock cap per command (independent of inactivityTimeout).
185
+ * A pathological server that returns one byte every (inactivityTimeout-1)
186
+ * seconds would otherwise defer the inactivity timer forever; this is
187
+ * the absolute deadline that always fires. Default 5 minutes. */
188
+ private commandWallClockTimeout;
176
189
  /** Server-greeting timeout in ms — how long to wait for the server's
177
190
  * initial banner after TCP/TLS connects. Default 10s; bumped to 30s
178
191
  * for slow shared-hosting Dovecot in mailx-imap. */
@@ -198,6 +211,11 @@ export declare class NativeImapClient {
198
211
  private waitForContinuation;
199
212
  private waitForTagged;
200
213
  private handleData;
214
+ /** Compact the buffer when bufferOffset has advanced past half. Cheap
215
+ * amortized cost; eliminates the O(n²) substring-each-line antipattern
216
+ * that synchronously blocked the daemon for 10 minutes on a large LIST
217
+ * response. */
218
+ private compactBufferIfNeeded;
201
219
  private processBuffer;
202
220
  private handleUntaggedResponse;
203
221
  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;
@@ -32,6 +40,10 @@ export class NativeImapClient {
32
40
  this.transport = transportFactory();
33
41
  this.verbose = config.verbose || false;
34
42
  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;
35
47
  this.fetchChunkSize = config.fetchChunkSize ?? 25;
36
48
  this.fetchChunkSizeMax = config.fetchChunkSizeMax ?? 500;
37
49
  this.greetingTimeout = config.greetingTimeout ?? 10000;
@@ -729,6 +741,11 @@ export class NativeImapClient {
729
741
  * 60s accommodates Gmail which is slow on SEARCH for large folders.
730
742
  * Overridable via ImapClientConfig.inactivityTimeout — slow Dovecot servers need 180s+. */
731
743
  inactivityTimeout;
744
+ /** Hard wall-clock cap per command (independent of inactivityTimeout).
745
+ * A pathological server that returns one byte every (inactivityTimeout-1)
746
+ * seconds would otherwise defer the inactivity timer forever; this is
747
+ * the absolute deadline that always fires. Default 5 minutes. */
748
+ commandWallClockTimeout;
732
749
  /** Server-greeting timeout in ms — how long to wait for the server's
733
750
  * initial banner after TCP/TLS connects. Default 10s; bumped to 30s
734
751
  * for slow shared-hosting Dovecot in mailx-imap. */
@@ -796,21 +813,39 @@ export class NativeImapClient {
796
813
  const waited = Date.now() - cmdStart;
797
814
  console.log(` [imap] still waiting for tag ${tag} after ${(waited / 1000).toFixed(1)}s — ${cmdSummary}${transportDiag()}`);
798
815
  }, HEARTBEAT_INTERVAL_MS);
799
- const onTimeout = () => {
816
+ const onTimeout = (kind) => {
800
817
  this.commandTimer = null;
801
818
  this.pendingCommand = null;
802
819
  if (heartbeatTimer) {
803
820
  clearInterval(heartbeatTimer);
804
821
  heartbeatTimer = null;
805
822
  }
823
+ if (wallClockTimer) {
824
+ clearTimeout(wallClockTimer);
825
+ wallClockTimer = null;
826
+ }
806
827
  // Kill the connection — a timed-out connection has stale data in the pipe.
807
828
  // Mark disconnected so ensureConnected() reconnects on the next call —
808
829
  // transport.close() may or may not fire onClose synchronously.
809
830
  this._connected = false;
810
831
  this.transport.close?.();
811
- reject(new Error(`IMAP inactivity timeout (${this.inactivityTimeout / 1000}s): ${cmdSummary}${transportDiag()}`));
832
+ reject(new Error(`${kind}: ${cmdSummary}${transportDiag()}`));
812
833
  };
813
- this.commandTimer = setTimeout(onTimeout, this.inactivityTimeout);
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).
846
+ 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);
814
849
  const clearTimers = () => {
815
850
  if (this.commandTimer) {
816
851
  clearTimeout(this.commandTimer);
@@ -820,6 +855,10 @@ export class NativeImapClient {
820
855
  clearInterval(heartbeatTimer);
821
856
  heartbeatTimer = null;
822
857
  }
858
+ if (wallClockTimer) {
859
+ clearTimeout(wallClockTimer);
860
+ wallClockTimer = null;
861
+ }
823
862
  };
824
863
  this.pendingCommand = {
825
864
  tag, responses: [],
@@ -890,6 +929,16 @@ export class NativeImapClient {
890
929
  this.buffer += data;
891
930
  this.processBuffer();
892
931
  }
932
+ /** Compact the buffer when bufferOffset has advanced past half. Cheap
933
+ * amortized cost; eliminates the O(n²) substring-each-line antipattern
934
+ * that synchronously blocked the daemon for 10 minutes on a large LIST
935
+ * response. */
936
+ compactBufferIfNeeded() {
937
+ if (this.bufferOffset > 0 && this.bufferOffset >= this.buffer.length / 2) {
938
+ this.buffer = this.buffer.substring(this.bufferOffset);
939
+ this.bufferOffset = 0;
940
+ }
941
+ }
893
942
  processBuffer() {
894
943
  while (true) {
895
944
  // Check for literal {size}\r\n — reading exact BYTE count of literal data
@@ -898,16 +947,20 @@ export class NativeImapClient {
898
947
  // We must use byte-accurate extraction (TextEncoder for browser, Buffer for Node).
899
948
  if (this.pendingCommand?.literalBytes != null) {
900
949
  const neededBytes = this.pendingCommand.literalBytes;
901
- const encoded = new TextEncoder().encode(this.buffer);
950
+ // Encode only the unread portion — was encoding the whole
951
+ // buffer (including already-consumed bytes) on every literal,
952
+ // which on a multi-MB buffer is a non-trivial chunk of work.
953
+ const unread = this.bufferOffset === 0 ? this.buffer : this.buffer.substring(this.bufferOffset);
954
+ const encoded = new TextEncoder().encode(unread);
902
955
  const bufferBytes = encoded.byteLength;
903
956
  if (bufferBytes >= neededBytes) {
904
957
  const literal_bytes = encoded.subarray(0, neededBytes);
905
958
  const rest_bytes = encoded.subarray(neededBytes);
906
959
  let literal = new TextDecoder().decode(literal_bytes);
960
+ // Reset buffer to just the rest — bufferOffset goes back
961
+ // to 0 since we replaced the buffer content.
907
962
  this.buffer = new TextDecoder().decode(rest_bytes);
908
- // if (neededBytes !== literal.length) {
909
- // console.log(`iflow-direct: [imap] literal: ${neededBytes} bytes → ${literal.length} chars (multi-byte corrected)`);
910
- // }
963
+ this.bufferOffset = 0;
911
964
  // For non-BODY literals (e.g. display names in ENVELOPE), wrap in quotes
912
965
  // so tokenizeParenList treats them as a single token
913
966
  if (!this.pendingCommand.currentLiteralKey) {
@@ -935,11 +988,23 @@ export class NativeImapClient {
935
988
  }
936
989
  break; // Wait for more data
937
990
  }
938
- const lineEnd = this.buffer.indexOf("\r\n");
939
- if (lineEnd < 0)
991
+ // Find next CRLF starting from the read offset (NOT from 0 —
992
+ // the bytes before bufferOffset are already consumed and
993
+ // searching them is dead work that scales with response size).
994
+ const lineEnd = this.buffer.indexOf("\r\n", this.bufferOffset);
995
+ if (lineEnd < 0) {
996
+ // No complete line yet. Compact the buffer if we've advanced
997
+ // far enough that the unconsumed-prefix is dragging.
998
+ this.compactBufferIfNeeded();
940
999
  break;
941
- const line = this.buffer.substring(0, lineEnd + 2);
942
- this.buffer = this.buffer.substring(lineEnd + 2);
1000
+ }
1001
+ // Extract the line and advance the read cursor — no substring
1002
+ // copy of the remainder, no buffer reallocation.
1003
+ const line = this.buffer.substring(this.bufferOffset, lineEnd + 2);
1004
+ this.bufferOffset = lineEnd + 2;
1005
+ // Periodic compaction so the consumed prefix doesn't keep
1006
+ // growing forever in long-running connections (IDLE, big sync).
1007
+ this.compactBufferIfNeeded();
943
1008
  // Check for literal announcement {size}\r\n at end of line
944
1009
  const literalMatch = line.match(/\{(\d+)\}\r\n$/);
945
1010
  if (literalMatch && this.pendingCommand) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/iflow-direct",
3
- "version": "0.1.32",
3
+ "version": "0.1.34",
4
4
  "description": "Direct IMAP client — transport-agnostic, no Node.js dependencies, browser-ready",
5
5
  "main": "index.js",
6
6
  "types": "index.ts",
package/types.d.ts CHANGED
@@ -11,6 +11,7 @@ export interface ImapClientConfig {
11
11
  verbose?: boolean;
12
12
  rejectUnauthorized?: boolean;
13
13
  inactivityTimeout?: number;
14
+ commandWallClockTimeout?: number;
14
15
  fetchChunkSize?: number;
15
16
  fetchChunkSizeMax?: number;
16
17
  greetingTimeout?: number;