@bobfrankston/iflow-direct 0.1.31 → 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 -22
  2. package/package.json +1 -1
package/imap-native.js CHANGED
@@ -771,43 +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
- // 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
810
  this.transport.close?.();
796
- reject(new Error(`IMAP inactivity timeout (${this.inactivityTimeout / 1000}s): ${command.split("\r")[0].substring(0, 80)}${diag}`));
811
+ reject(new Error(`IMAP inactivity timeout (${this.inactivityTimeout / 1000}s): ${cmdSummary}${transportDiag()}`));
797
812
  };
798
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
+ };
799
824
  this.pendingCommand = {
800
825
  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); },
826
+ resolve: (responses) => { clearTimers(); resolve(responses); },
827
+ reject: (err) => { clearTimers(); reject(err); },
805
828
  onUntagged,
806
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 */ }
807
845
  this.transport.write(command).catch((err) => {
808
- if (this.commandTimer)
809
- clearTimeout(this.commandTimer);
810
- this.commandTimer = null;
846
+ clearTimers();
811
847
  // Write failure = dead socket. Clear pendingCommand + disconnect flag
812
848
  // so the next ensureConnected() does a fresh connect instead of
813
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.31",
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",