@bobfrankston/mailx-imap 0.1.29 → 0.1.30

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/index.js +41 -49
  2. package/package.json +3 -3
package/index.js CHANGED
@@ -1111,21 +1111,13 @@ export class ImapManager extends EventEmitter {
1111
1111
  const existingSet = new Set(existingUids);
1112
1112
  const newSet = new Set(messages.map(m => m.uid));
1113
1113
  const missingUids = allServerUids.filter((uid) => !existingSet.has(uid) && !newSet.has(uid));
1114
- // Backfill chunk size: ops queue yields between chunks so
1115
- // a click-time body fetch can interleave. 100 UIDs per
1116
- // chunk balances throughput (one FETCH command per chunk)
1117
- // against latency (a chunk takes 1-3s on Dovecot, which
1118
- // is the worst-case wait an interactive click endures).
1119
- // The 500-chunk version held the ops queue for the
1120
- // entire backfill — Bob 2026-05-08 saw a click-to-render
1121
- // wait of 100+ minutes on a busy backfill of the IP
1122
- // folder.
1114
+ // Backfill chunk size. Use the passed-in `client` directly
1115
+ // (NOT a nested withConnection) syncFolder is now wrapped
1116
+ // in withConnection at the call site, so the slow lane is
1117
+ // already locked for our duration. A nested withConnection
1118
+ // would deadlock waiting for the slot we hold.
1123
1119
  const BACKFILL_CHUNK_SIZE = 100;
1124
1120
  if (missingUids.length > 0 && missingUids.length <= 5000) {
1125
- // For the log line we report a count; computing
1126
- // min/max via a spread (`Math.min(...arr)`) blows V8's
1127
- // argument limit on folders with tens of thousands of
1128
- // UIDs. Use a manual reduce.
1129
1121
  let minU = existingUids[0] ?? 0;
1130
1122
  for (let i = 1; i < existingUids.length; i++)
1131
1123
  if (existingUids[i] < minU)
@@ -1135,13 +1127,7 @@ export class ImapManager extends EventEmitter {
1135
1127
  for (let i = 0; i < missingUids.length; i += BACKFILL_CHUNK_SIZE) {
1136
1128
  const chunk = missingUids.slice(i, i + BACKFILL_CHUNK_SIZE);
1137
1129
  const range = chunk.join(",");
1138
- // Each chunk gets its own withConnection slow-lane
1139
- // turn so any fast-lane click queued in the
1140
- // meantime gets serviced between chunks. The
1141
- // outer `client` param is bypassed here; the
1142
- // queue-managed client is the same persistent
1143
- // ops client (getOpsClient).
1144
- const recovered = await this.withConnection(accountId, async (c) => await c.fetchMessages(folder.path, range, { source: false }), { slow: true });
1130
+ const recovered = await client.fetchMessages(folder.path, range, { source: false });
1145
1131
  messages.push(...recovered);
1146
1132
  recoveredTotal += recovered.length;
1147
1133
  console.log(` ${folder.path}: fetch ${recoveredTotal}/${missingUids.length}`);
@@ -1149,21 +1135,15 @@ export class ImapManager extends EventEmitter {
1149
1135
  }
1150
1136
  else if (missingUids.length > 5000) {
1151
1137
  console.log(` ${folder.path}: ${missingUids.length} server-only UIDs — capped; will resume next cycle`);
1152
- // ASSUMPTION: vanilla IMAP under stable UIDVALIDITY,
1153
- // higher UID = later assignment ≈ more recent message.
1154
- // True for Dovecot / Cyrus / standard IMAP, which is the
1155
- // only place this code path runs (Gmail-API mode goes
1156
- // through syncAccountViaApi and doesn't reach here —
1157
- // its synthesized hash-UIDs have no temporal meaning).
1158
- // If we ever wire this for a non-monotonic UID source,
1159
- // sort by date instead — but that means an extra
1160
- // fetch-of-INTERNALDATE round-trip, which we avoid for
1161
- // free here under the IMAP guarantee.
1138
+ // Vanilla IMAP under stable UIDVALIDITY: higher UID =
1139
+ // later assignment ≈ more recent message (Dovecot/
1140
+ // Cyrus). Gmail-API path is separate (no temporal
1141
+ // meaning to its hash-UIDs).
1162
1142
  const cappedSlice = missingUids.sort((a, b) => b - a).slice(0, 5000);
1163
1143
  let recoveredTotal = 0;
1164
1144
  for (let i = 0; i < cappedSlice.length; i += BACKFILL_CHUNK_SIZE) {
1165
1145
  const chunk = cappedSlice.slice(i, i + BACKFILL_CHUNK_SIZE);
1166
- const recovered = await this.withConnection(accountId, async (c) => await c.fetchMessages(folder.path, chunk.join(","), { source: false }), { slow: true });
1146
+ const recovered = await client.fetchMessages(folder.path, chunk.join(","), { source: false });
1167
1147
  messages.push(...recovered);
1168
1148
  recoveredTotal += recovered.length;
1169
1149
  console.log(` ${folder.path}: fetch ${recoveredTotal}/5000 (capped)`);
@@ -1547,27 +1527,39 @@ export class ImapManager extends EventEmitter {
1547
1527
  const highestUid = this.db.getHighestUid(accountId, folder.id);
1548
1528
  if (isTrashChild && highestUid === 0)
1549
1529
  return;
1550
- let fresh = null;
1530
+ let clientForDiag = null;
1551
1531
  try {
1552
- fresh = await this.getOpsClient(accountId);
1553
- await Promise.race([
1554
- this.syncFolder(accountId, folder.id, fresh),
1555
- new Promise((_, reject) => setTimeout(() => {
1556
- // C120: pull TCP transport diagnostics into the
1557
- // per-folder timeout error so the [sync] log
1558
- // distinguishes "server stopped responding"
1559
- // (sinceLastRead high) from "we never finished
1560
- // writing" (writes climbing without reads). Same
1561
- // shape iflow-direct emits on its own timeouts.
1562
- const d = fresh?.transport?.diagnostics;
1563
- const diag = d
1564
- ? ` [conn#${d.connId} r=${d.bytesRead}B w=${d.bytesWritten}B writes=${d.writeCount} sinceLastRead=${d.lastReadAt ? Date.now() - d.lastReadAt : -1}ms]`
1565
- : "";
1566
- reject(new Error(`per-folder timeout (${PER_FOLDER_TIMEOUT_MS / 1000}s): ${folder.path}${diag}`));
1567
- }, PER_FOLDER_TIMEOUT_MS)),
1568
- ]);
1532
+ // Route syncFolder through the slow-lane queue so prefetch
1533
+ // (also slow lane) and sync take strict turns on the slow
1534
+ // client. Previously syncOne grabbed `getOpsClient` and
1535
+ // ran syncFolder directly OUTSIDE the queue; prefetch
1536
+ // chunks via withConnection raced against it on the same
1537
+ // client. Symptom: prefetch sent `SELECT INBOX` then sync
1538
+ // sent `SELECT Sent/Drafts`, then prefetch's `UID FETCH
1539
+ // <inbox-uids>` ran against Drafts 0 bodies returned →
1540
+ // prefetch logs "0/N NOT pruning" but bodies never
1541
+ // download. With C123 the fast lane has its own
1542
+ // independent client, so wrapping sync in slow-lane
1543
+ // withConnection doesn't block click-time body fetches.
1544
+ await this.withConnection(accountId, async (client) => {
1545
+ clientForDiag = client;
1546
+ await this.syncFolder(accountId, folder.id, client);
1547
+ }, { slow: true, timeoutMs: PER_FOLDER_TIMEOUT_MS });
1569
1548
  }
1570
1549
  catch (e) {
1550
+ // C120: per-folder timeout error appends transport
1551
+ // diagnostics so the [sync] log distinguishes "server
1552
+ // stopped responding" (sinceLastRead high) from "we
1553
+ // never finished writing" (writes climbing without
1554
+ // reads). The withConnection timeout already includes
1555
+ // its own message; we annotate further only for the
1556
+ // timeout path.
1557
+ if (/timeout/i.test(e?.message || "")) {
1558
+ const d = clientForDiag?.transport?.diagnostics;
1559
+ if (d) {
1560
+ e.message = `${e.message} [conn#${d.connId} r=${d.bytesRead}B w=${d.bytesWritten}B writes=${d.writeCount} sinceLastRead=${d.lastReadAt ? Date.now() - d.lastReadAt : -1}ms] folder=${folder.path}`;
1561
+ }
1562
+ }
1571
1563
  if (e.responseText?.includes("doesn't exist")) {
1572
1564
  this.db.deleteFolder(folder.id);
1573
1565
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.29",
3
+ "version": "0.1.30",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -14,7 +14,7 @@
14
14
  "@bobfrankston/mailx-store": "^0.1.15",
15
15
  "@bobfrankston/iflow-direct": "^0.1.39",
16
16
  "@bobfrankston/tcp-transport": "^0.1.6",
17
- "@bobfrankston/smtp-direct": "^0.1.6",
17
+ "@bobfrankston/smtp-direct": "^0.1.8",
18
18
  "@bobfrankston/mailx-sync": "^0.1.16",
19
19
  "@bobfrankston/oauthsupport": "^1.0.26"
20
20
  },
@@ -42,7 +42,7 @@
42
42
  "@bobfrankston/mailx-store": "^0.1.15",
43
43
  "@bobfrankston/iflow-direct": "^0.1.39",
44
44
  "@bobfrankston/tcp-transport": "^0.1.6",
45
- "@bobfrankston/smtp-direct": "^0.1.6",
45
+ "@bobfrankston/smtp-direct": "^0.1.8",
46
46
  "@bobfrankston/mailx-sync": "^0.1.16",
47
47
  "@bobfrankston/oauthsupport": "^1.0.26"
48
48
  }