@bobfrankston/rmfmail 1.0.589 → 1.0.592

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.
@@ -1159,21 +1159,13 @@ export class ImapManager extends EventEmitter {
1159
1159
  const missingUids = allServerUids.filter((uid: number) =>
1160
1160
  !existingSet.has(uid) && !newSet.has(uid)
1161
1161
  );
1162
- // Backfill chunk size: ops queue yields between chunks so
1163
- // a click-time body fetch can interleave. 100 UIDs per
1164
- // chunk balances throughput (one FETCH command per chunk)
1165
- // against latency (a chunk takes 1-3s on Dovecot, which
1166
- // is the worst-case wait an interactive click endures).
1167
- // The 500-chunk version held the ops queue for the
1168
- // entire backfill — Bob 2026-05-08 saw a click-to-render
1169
- // wait of 100+ minutes on a busy backfill of the IP
1170
- // folder.
1162
+ // Backfill chunk size. Use the passed-in `client` directly
1163
+ // (NOT a nested withConnection) syncFolder is now wrapped
1164
+ // in withConnection at the call site, so the slow lane is
1165
+ // already locked for our duration. A nested withConnection
1166
+ // would deadlock waiting for the slot we hold.
1171
1167
  const BACKFILL_CHUNK_SIZE = 100;
1172
1168
  if (missingUids.length > 0 && missingUids.length <= 5000) {
1173
- // For the log line we report a count; computing
1174
- // min/max via a spread (`Math.min(...arr)`) blows V8's
1175
- // argument limit on folders with tens of thousands of
1176
- // UIDs. Use a manual reduce.
1177
1169
  let minU = existingUids[0] ?? 0;
1178
1170
  for (let i = 1; i < existingUids.length; i++) if (existingUids[i] < minU) minU = existingUids[i];
1179
1171
  console.log(` ${folder.path}: ${missingUids.length} server-only UIDs (local lowest=${minU}, highest=${highestUid}) — fetching`);
@@ -1181,40 +1173,22 @@ export class ImapManager extends EventEmitter {
1181
1173
  for (let i = 0; i < missingUids.length; i += BACKFILL_CHUNK_SIZE) {
1182
1174
  const chunk = missingUids.slice(i, i + BACKFILL_CHUNK_SIZE);
1183
1175
  const range = chunk.join(",");
1184
- // Each chunk gets its own withConnection slow-lane
1185
- // turn so any fast-lane click queued in the
1186
- // meantime gets serviced between chunks. The
1187
- // outer `client` param is bypassed here; the
1188
- // queue-managed client is the same persistent
1189
- // ops client (getOpsClient).
1190
- const recovered = await this.withConnection(accountId, async (c) =>
1191
- await (c as any).fetchMessages(folder.path, range, { source: false }),
1192
- { slow: true },
1193
- );
1176
+ const recovered = await (client as any).fetchMessages(folder.path, range, { source: false });
1194
1177
  messages.push(...recovered);
1195
1178
  recoveredTotal += recovered.length;
1196
1179
  console.log(` ${folder.path}: fetch ${recoveredTotal}/${missingUids.length}`);
1197
1180
  }
1198
1181
  } else if (missingUids.length > 5000) {
1199
1182
  console.log(` ${folder.path}: ${missingUids.length} server-only UIDs — capped; will resume next cycle`);
1200
- // ASSUMPTION: vanilla IMAP under stable UIDVALIDITY,
1201
- // higher UID = later assignment ≈ more recent message.
1202
- // True for Dovecot / Cyrus / standard IMAP, which is the
1203
- // only place this code path runs (Gmail-API mode goes
1204
- // through syncAccountViaApi and doesn't reach here —
1205
- // its synthesized hash-UIDs have no temporal meaning).
1206
- // If we ever wire this for a non-monotonic UID source,
1207
- // sort by date instead — but that means an extra
1208
- // fetch-of-INTERNALDATE round-trip, which we avoid for
1209
- // free here under the IMAP guarantee.
1183
+ // Vanilla IMAP under stable UIDVALIDITY: higher UID =
1184
+ // later assignment ≈ more recent message (Dovecot/
1185
+ // Cyrus). Gmail-API path is separate (no temporal
1186
+ // meaning to its hash-UIDs).
1210
1187
  const cappedSlice = missingUids.sort((a: number, b: number) => b - a).slice(0, 5000);
1211
1188
  let recoveredTotal = 0;
1212
1189
  for (let i = 0; i < cappedSlice.length; i += BACKFILL_CHUNK_SIZE) {
1213
1190
  const chunk = cappedSlice.slice(i, i + BACKFILL_CHUNK_SIZE);
1214
- const recovered = await this.withConnection(accountId, async (c) =>
1215
- await (c as any).fetchMessages(folder.path, chunk.join(","), { source: false }),
1216
- { slow: true },
1217
- );
1191
+ const recovered = await (client as any).fetchMessages(folder.path, chunk.join(","), { source: false });
1218
1192
  messages.push(...recovered);
1219
1193
  recoveredTotal += recovered.length;
1220
1194
  console.log(` ${folder.path}: fetch ${recoveredTotal}/5000 (capped)`);
@@ -1610,26 +1584,38 @@ export class ImapManager extends EventEmitter {
1610
1584
  const isTrashChild = folder.path.includes("/") && folder.path.toLowerCase().startsWith("trash");
1611
1585
  const highestUid = this.db.getHighestUid(accountId, folder.id);
1612
1586
  if (isTrashChild && highestUid === 0) return;
1613
- let fresh: any = null;
1587
+ let clientForDiag: any = null;
1614
1588
  try {
1615
- fresh = await this.getOpsClient(accountId);
1616
- await Promise.race([
1617
- this.syncFolder(accountId, folder.id, fresh),
1618
- new Promise((_, reject) => setTimeout(() => {
1619
- // C120: pull TCP transport diagnostics into the
1620
- // per-folder timeout error so the [sync] log
1621
- // distinguishes "server stopped responding"
1622
- // (sinceLastRead high) from "we never finished
1623
- // writing" (writes climbing without reads). Same
1624
- // shape iflow-direct emits on its own timeouts.
1625
- const d = (fresh as any)?.transport?.diagnostics;
1626
- const diag = d
1627
- ? ` [conn#${d.connId} r=${d.bytesRead}B w=${d.bytesWritten}B writes=${d.writeCount} sinceLastRead=${d.lastReadAt ? Date.now() - d.lastReadAt : -1}ms]`
1628
- : "";
1629
- reject(new Error(`per-folder timeout (${PER_FOLDER_TIMEOUT_MS / 1000}s): ${folder.path}${diag}`));
1630
- }, PER_FOLDER_TIMEOUT_MS)),
1631
- ]);
1589
+ // Route syncFolder through the slow-lane queue so prefetch
1590
+ // (also slow lane) and sync take strict turns on the slow
1591
+ // client. Previously syncOne grabbed `getOpsClient` and
1592
+ // ran syncFolder directly OUTSIDE the queue; prefetch
1593
+ // chunks via withConnection raced against it on the same
1594
+ // client. Symptom: prefetch sent `SELECT INBOX` then sync
1595
+ // sent `SELECT Sent/Drafts`, then prefetch's `UID FETCH
1596
+ // <inbox-uids>` ran against Drafts 0 bodies returned →
1597
+ // prefetch logs "0/N NOT pruning" but bodies never
1598
+ // download. With C123 the fast lane has its own
1599
+ // independent client, so wrapping sync in slow-lane
1600
+ // withConnection doesn't block click-time body fetches.
1601
+ await this.withConnection(accountId, async (client) => {
1602
+ clientForDiag = client;
1603
+ await this.syncFolder(accountId, folder.id, client);
1604
+ }, { slow: true, timeoutMs: PER_FOLDER_TIMEOUT_MS });
1632
1605
  } catch (e: any) {
1606
+ // C120: per-folder timeout error appends transport
1607
+ // diagnostics so the [sync] log distinguishes "server
1608
+ // stopped responding" (sinceLastRead high) from "we
1609
+ // never finished writing" (writes climbing without
1610
+ // reads). The withConnection timeout already includes
1611
+ // its own message; we annotate further only for the
1612
+ // timeout path.
1613
+ if (/timeout/i.test(e?.message || "")) {
1614
+ const d = (clientForDiag as any)?.transport?.diagnostics;
1615
+ if (d) {
1616
+ 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}`;
1617
+ }
1618
+ }
1633
1619
  if (e.responseText?.includes("doesn't exist")) {
1634
1620
  this.db.deleteFolder(folder.id);
1635
1621
  } else {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.29",
3
+ "version": "0.1.30",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bobfrankston/mailx-imap",
9
- "version": "0.1.29",
9
+ "version": "0.1.30",
10
10
  "license": "ISC",
11
11
  "dependencies": {
12
12
  "@bobfrankston/iflow-direct": "^0.1.27",
@@ -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",