@bobfrankston/iflow-direct 0.1.30 → 0.1.32

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.
Files changed (2) hide show
  1. package/imap-native.js +58 -8
  2. package/package.json +1 -1
package/imap-native.js CHANGED
@@ -771,29 +771,79 @@ export class NativeImapClient {
771
771
  if (this.verbose && !command.includes("LOGIN") && !command.includes("AUTHENTICATE")) {
772
772
  console.log(` [imap] > ${command.trimEnd()}`);
773
773
  }
774
+ // Snapshot transport diagnostics — used by both heartbeat and timeout.
775
+ const transportDiag = () => {
776
+ try {
777
+ const d = this.transport?.diagnostics;
778
+ if (!d)
779
+ return "";
780
+ const sinceRead = d.lastReadAt ? Date.now() - d.lastReadAt : -1;
781
+ return ` [conn#${d.connId} r=${d.bytesRead}B w=${d.bytesWritten}B writes=${d.writeCount} sinceLastRead=${sinceRead}ms]`;
782
+ }
783
+ catch {
784
+ return "";
785
+ }
786
+ };
787
+ // HEARTBEAT_INTERVAL_MS — periodic progress log while a command
788
+ // is pending. Catches wedges in flight rather than only at the
789
+ // 300s timeout edge. Key signal: if `sinceLastRead` keeps
790
+ // climbing, the socket is silently dead. If reads are happening
791
+ // but the tagged OK never arrives, the parser is stuck.
792
+ const HEARTBEAT_INTERVAL_MS = 30_000;
793
+ const cmdStart = Date.now();
794
+ const cmdSummary = command.split("\r")[0].substring(0, 80);
795
+ let heartbeatTimer = setInterval(() => {
796
+ const waited = Date.now() - cmdStart;
797
+ console.log(` [imap] still waiting for tag ${tag} after ${(waited / 1000).toFixed(1)}s — ${cmdSummary}${transportDiag()}`);
798
+ }, HEARTBEAT_INTERVAL_MS);
774
799
  const onTimeout = () => {
775
800
  this.commandTimer = null;
776
801
  this.pendingCommand = null;
802
+ if (heartbeatTimer) {
803
+ clearInterval(heartbeatTimer);
804
+ heartbeatTimer = null;
805
+ }
777
806
  // Kill the connection — a timed-out connection has stale data in the pipe.
778
807
  // Mark disconnected so ensureConnected() reconnects on the next call —
779
808
  // transport.close() may or may not fire onClose synchronously.
780
809
  this._connected = false;
781
810
  this.transport.close?.();
782
- reject(new Error(`IMAP inactivity timeout (${this.inactivityTimeout / 1000}s): ${command.split("\r")[0].substring(0, 80)}`));
811
+ reject(new Error(`IMAP inactivity timeout (${this.inactivityTimeout / 1000}s): ${cmdSummary}${transportDiag()}`));
783
812
  };
784
813
  this.commandTimer = setTimeout(onTimeout, this.inactivityTimeout);
814
+ const clearTimers = () => {
815
+ if (this.commandTimer) {
816
+ clearTimeout(this.commandTimer);
817
+ this.commandTimer = null;
818
+ }
819
+ if (heartbeatTimer) {
820
+ clearInterval(heartbeatTimer);
821
+ heartbeatTimer = null;
822
+ }
823
+ };
785
824
  this.pendingCommand = {
786
825
  tag, responses: [],
787
- resolve: (responses) => { if (this.commandTimer)
788
- clearTimeout(this.commandTimer); this.commandTimer = null; resolve(responses); },
789
- reject: (err) => { if (this.commandTimer)
790
- clearTimeout(this.commandTimer); this.commandTimer = null; reject(err); },
826
+ resolve: (responses) => { clearTimers(); resolve(responses); },
827
+ reject: (err) => { clearTimers(); reject(err); },
791
828
  onUntagged,
792
829
  };
830
+ // PRE-WRITE HEALTH CHECK — verify the socket is in a writable
831
+ // state BEFORE we burn the inactivityTimeout window waiting for
832
+ // a reply that can't come. socket-half-closed (peer FIN'd) and
833
+ // destroyed states both fail fast here instead of after 300s.
834
+ try {
835
+ const h = this.transport?.socketHealth;
836
+ if (h && (h.destroyed || h.writableEnded || !h.writable)) {
837
+ clearTimers();
838
+ this.pendingCommand = null;
839
+ this._connected = false;
840
+ reject(new Error(`IMAP socket dead before write: destroyed=${h.destroyed} writableEnded=${h.writableEnded} writable=${h.writable}${transportDiag()}`));
841
+ return;
842
+ }
843
+ }
844
+ catch { /* transport may not expose socketHealth — fall through */ }
793
845
  this.transport.write(command).catch((err) => {
794
- if (this.commandTimer)
795
- clearTimeout(this.commandTimer);
796
- this.commandTimer = null;
846
+ clearTimers();
797
847
  // Write failure = dead socket. Clear pendingCommand + disconnect flag
798
848
  // so the next ensureConnected() does a fresh connect instead of
799
849
  // 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.30",
3
+ "version": "0.1.32",
4
4
  "description": "Direct IMAP client — transport-agnostic, no Node.js dependencies, browser-ready",
5
5
  "main": "index.js",
6
6
  "types": "index.ts",