@bobfrankston/iflow-direct 0.1.31 → 0.1.33

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
@@ -173,6 +173,11 @@ export declare class NativeImapClient {
173
173
  * 60s accommodates Gmail which is slow on SEARCH for large folders.
174
174
  * Overridable via ImapClientConfig.inactivityTimeout — slow Dovecot servers need 180s+. */
175
175
  private inactivityTimeout;
176
+ /** Hard wall-clock cap per command (independent of inactivityTimeout).
177
+ * A pathological server that returns one byte every (inactivityTimeout-1)
178
+ * seconds would otherwise defer the inactivity timer forever; this is
179
+ * the absolute deadline that always fires. Default 5 minutes. */
180
+ private commandWallClockTimeout;
176
181
  /** Server-greeting timeout in ms — how long to wait for the server's
177
182
  * initial banner after TCP/TLS connects. Default 10s; bumped to 30s
178
183
  * for slow shared-hosting Dovecot in mailx-imap. */
package/imap-native.js CHANGED
@@ -32,6 +32,10 @@ export class NativeImapClient {
32
32
  this.transport = transportFactory();
33
33
  this.verbose = config.verbose || false;
34
34
  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;
35
39
  this.fetchChunkSize = config.fetchChunkSize ?? 25;
36
40
  this.fetchChunkSizeMax = config.fetchChunkSizeMax ?? 500;
37
41
  this.greetingTimeout = config.greetingTimeout ?? 10000;
@@ -729,6 +733,11 @@ export class NativeImapClient {
729
733
  * 60s accommodates Gmail which is slow on SEARCH for large folders.
730
734
  * Overridable via ImapClientConfig.inactivityTimeout — slow Dovecot servers need 180s+. */
731
735
  inactivityTimeout;
736
+ /** Hard wall-clock cap per command (independent of inactivityTimeout).
737
+ * A pathological server that returns one byte every (inactivityTimeout-1)
738
+ * seconds would otherwise defer the inactivity timer forever; this is
739
+ * the absolute deadline that always fires. Default 5 minutes. */
740
+ commandWallClockTimeout;
732
741
  /** Server-greeting timeout in ms — how long to wait for the server's
733
742
  * initial banner after TCP/TLS connects. Default 10s; bumped to 30s
734
743
  * for slow shared-hosting Dovecot in mailx-imap. */
@@ -771,43 +780,101 @@ export class NativeImapClient {
771
780
  if (this.verbose && !command.includes("LOGIN") && !command.includes("AUTHENTICATE")) {
772
781
  console.log(` [imap] > ${command.trimEnd()}`);
773
782
  }
774
- const onTimeout = () => {
783
+ // Snapshot transport diagnostics used by both heartbeat and timeout.
784
+ const transportDiag = () => {
785
+ try {
786
+ const d = this.transport?.diagnostics;
787
+ if (!d)
788
+ return "";
789
+ const sinceRead = d.lastReadAt ? Date.now() - d.lastReadAt : -1;
790
+ return ` [conn#${d.connId} r=${d.bytesRead}B w=${d.bytesWritten}B writes=${d.writeCount} sinceLastRead=${sinceRead}ms]`;
791
+ }
792
+ catch {
793
+ return "";
794
+ }
795
+ };
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;
802
+ const cmdStart = Date.now();
803
+ 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
+ const onTimeout = (kind) => {
775
809
  this.commandTimer = null;
776
810
  this.pendingCommand = null;
811
+ if (heartbeatTimer) {
812
+ clearInterval(heartbeatTimer);
813
+ heartbeatTimer = null;
814
+ }
815
+ if (wallClockTimer) {
816
+ clearTimeout(wallClockTimer);
817
+ wallClockTimer = null;
818
+ }
777
819
  // Kill the connection — a timed-out connection has stale data in the pipe.
778
820
  // Mark disconnected so ensureConnected() reconnects on the next call —
779
821
  // transport.close() may or may not fire onClose synchronously.
780
822
  this._connected = false;
781
- // Surface transport-level diagnostics if available — bytes
782
- // written/read, time since last read, write count, conn id.
783
- // Tells us whether the connection was silently dead
784
- // (no reads, no writes acked) or genuinely waiting on a
785
- // server reply that never came.
786
- let diag = "";
787
- try {
788
- const d = this.transport?.diagnostics;
789
- if (d) {
790
- const sinceRead = d.lastReadAt ? Date.now() - d.lastReadAt : -1;
791
- diag = ` [conn#${d.connId} r=${d.bytesRead}B w=${d.bytesWritten}B writes=${d.writeCount} sinceLastRead=${sinceRead}ms]`;
792
- }
793
- }
794
- catch { /* ignore */ }
795
823
  this.transport.close?.();
796
- reject(new Error(`IMAP inactivity timeout (${this.inactivityTimeout / 1000}s): ${command.split("\r")[0].substring(0, 80)}${diag}`));
824
+ reject(new Error(`${kind}: ${cmdSummary}${transportDiag()}`));
825
+ };
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).
838
+ 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
+ }
797
854
  };
798
- this.commandTimer = setTimeout(onTimeout, this.inactivityTimeout);
799
855
  this.pendingCommand = {
800
856
  tag, responses: [],
801
- resolve: (responses) => { if (this.commandTimer)
802
- clearTimeout(this.commandTimer); this.commandTimer = null; resolve(responses); },
803
- reject: (err) => { if (this.commandTimer)
804
- clearTimeout(this.commandTimer); this.commandTimer = null; reject(err); },
857
+ resolve: (responses) => { clearTimers(); resolve(responses); },
858
+ reject: (err) => { clearTimers(); reject(err); },
805
859
  onUntagged,
806
860
  };
861
+ // PRE-WRITE HEALTH CHECK — verify the socket is in a writable
862
+ // state BEFORE we burn the inactivityTimeout window waiting for
863
+ // a reply that can't come. socket-half-closed (peer FIN'd) and
864
+ // destroyed states both fail fast here instead of after 300s.
865
+ try {
866
+ const h = this.transport?.socketHealth;
867
+ if (h && (h.destroyed || h.writableEnded || !h.writable)) {
868
+ clearTimers();
869
+ this.pendingCommand = null;
870
+ this._connected = false;
871
+ reject(new Error(`IMAP socket dead before write: destroyed=${h.destroyed} writableEnded=${h.writableEnded} writable=${h.writable}${transportDiag()}`));
872
+ return;
873
+ }
874
+ }
875
+ catch { /* transport may not expose socketHealth — fall through */ }
807
876
  this.transport.write(command).catch((err) => {
808
- if (this.commandTimer)
809
- clearTimeout(this.commandTimer);
810
- this.commandTimer = null;
877
+ clearTimers();
811
878
  // Write failure = dead socket. Clear pendingCommand + disconnect flag
812
879
  // so the next ensureConnected() does a fresh connect instead of
813
880
  // reusing a socket that the OS layer has already declared dead
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/iflow-direct",
3
- "version": "0.1.31",
3
+ "version": "0.1.33",
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;