@bobfrankston/rmfmail 1.0.590 → 1.0.594

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.
@@ -1013,7 +1013,43 @@ export class ImapManager extends EventEmitter {
1013
1013
  }
1014
1014
 
1015
1015
  /** Sync messages for a specific folder */
1016
+ /** Per-(accountId,folderId) sync lock. Multiple paths can call syncFolder
1017
+ * for the same folder concurrently — `syncOne` (full sync), `syncInbox`
1018
+ * (5-min fast poll), `quickInboxCheckAccount` (startup quick check),
1019
+ * `syncInboxNewOnly` (IDLE callback). Each path takes a different
1020
+ * connection, so withConnection can't serialize them. The DB layer
1021
+ * enforces "one transaction per connection" but doesn't notice the
1022
+ * concurrent UID set being mutated underneath. Symptom: two
1023
+ * `[sync-enter]` log lines for the same folder within ms (Bob 2026-05-08
1024
+ * 21:47:52), `cannot start a transaction within a transaction` SQLite
1025
+ * errors, and prefetch SELECT/FETCH races when sync's SELECT runs
1026
+ * between prefetch's SELECT and FETCH.
1027
+ *
1028
+ * This per-folder mutex means the second concurrent caller waits
1029
+ * rather than silently racing. The waiter still gets the lock when
1030
+ * the first caller finishes — important for outbox flushes that
1031
+ * expect their syncFolder for `Sent` to actually run. */
1032
+ private syncFolderLocks = new Map<string, Promise<number>>();
1033
+
1016
1034
  async syncFolder(accountId: string, folderId: number, client?: any): Promise<number> {
1035
+ const lockKey = `${accountId}:${folderId}`;
1036
+ const inflight = this.syncFolderLocks.get(lockKey);
1037
+ if (inflight) {
1038
+ // Coalesce: callers that fire while a sync is in flight get the
1039
+ // result of the in-flight call rather than starting a duplicate.
1040
+ // For "quick check that finds new mail and triggers sync" this
1041
+ // means the quick check waits for the existing sync to complete
1042
+ // — which is what the user wants anyway, no double work.
1043
+ console.log(` [sync-enter] ${accountId}/${folderId}: coalescing (sync already in flight)`);
1044
+ return inflight;
1045
+ }
1046
+ const promise = this._syncFolderImpl(accountId, folderId, client)
1047
+ .finally(() => { this.syncFolderLocks.delete(lockKey); });
1048
+ this.syncFolderLocks.set(lockKey, promise);
1049
+ return promise;
1050
+ }
1051
+
1052
+ private async _syncFolderImpl(accountId: string, folderId: number, client?: any): Promise<number> {
1017
1053
  if (!client) client = await this.getOpsClient(accountId);
1018
1054
  const prefetch = getPrefetch();
1019
1055
 
@@ -1159,21 +1195,13 @@ export class ImapManager extends EventEmitter {
1159
1195
  const missingUids = allServerUids.filter((uid: number) =>
1160
1196
  !existingSet.has(uid) && !newSet.has(uid)
1161
1197
  );
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.
1198
+ // Backfill chunk size. Use the passed-in `client` directly
1199
+ // (NOT a nested withConnection) syncFolder is now wrapped
1200
+ // in withConnection at the call site, so the slow lane is
1201
+ // already locked for our duration. A nested withConnection
1202
+ // would deadlock waiting for the slot we hold.
1171
1203
  const BACKFILL_CHUNK_SIZE = 100;
1172
1204
  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
1205
  let minU = existingUids[0] ?? 0;
1178
1206
  for (let i = 1; i < existingUids.length; i++) if (existingUids[i] < minU) minU = existingUids[i];
1179
1207
  console.log(` ${folder.path}: ${missingUids.length} server-only UIDs (local lowest=${minU}, highest=${highestUid}) — fetching`);
@@ -1181,40 +1209,22 @@ export class ImapManager extends EventEmitter {
1181
1209
  for (let i = 0; i < missingUids.length; i += BACKFILL_CHUNK_SIZE) {
1182
1210
  const chunk = missingUids.slice(i, i + BACKFILL_CHUNK_SIZE);
1183
1211
  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
- );
1212
+ const recovered = await (client as any).fetchMessages(folder.path, range, { source: false });
1194
1213
  messages.push(...recovered);
1195
1214
  recoveredTotal += recovered.length;
1196
1215
  console.log(` ${folder.path}: fetch ${recoveredTotal}/${missingUids.length}`);
1197
1216
  }
1198
1217
  } else if (missingUids.length > 5000) {
1199
1218
  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.
1219
+ // Vanilla IMAP under stable UIDVALIDITY: higher UID =
1220
+ // later assignment ≈ more recent message (Dovecot/
1221
+ // Cyrus). Gmail-API path is separate (no temporal
1222
+ // meaning to its hash-UIDs).
1210
1223
  const cappedSlice = missingUids.sort((a: number, b: number) => b - a).slice(0, 5000);
1211
1224
  let recoveredTotal = 0;
1212
1225
  for (let i = 0; i < cappedSlice.length; i += BACKFILL_CHUNK_SIZE) {
1213
1226
  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
- );
1227
+ const recovered = await (client as any).fetchMessages(folder.path, chunk.join(","), { source: false });
1218
1228
  messages.push(...recovered);
1219
1229
  recoveredTotal += recovered.length;
1220
1230
  console.log(` ${folder.path}: fetch ${recoveredTotal}/5000 (capped)`);
@@ -1610,26 +1620,38 @@ export class ImapManager extends EventEmitter {
1610
1620
  const isTrashChild = folder.path.includes("/") && folder.path.toLowerCase().startsWith("trash");
1611
1621
  const highestUid = this.db.getHighestUid(accountId, folder.id);
1612
1622
  if (isTrashChild && highestUid === 0) return;
1613
- let fresh: any = null;
1623
+ let clientForDiag: any = null;
1614
1624
  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
- ]);
1625
+ // Route syncFolder through the slow-lane queue so prefetch
1626
+ // (also slow lane) and sync take strict turns on the slow
1627
+ // client. Previously syncOne grabbed `getOpsClient` and
1628
+ // ran syncFolder directly OUTSIDE the queue; prefetch
1629
+ // chunks via withConnection raced against it on the same
1630
+ // client. Symptom: prefetch sent `SELECT INBOX` then sync
1631
+ // sent `SELECT Sent/Drafts`, then prefetch's `UID FETCH
1632
+ // <inbox-uids>` ran against Drafts 0 bodies returned →
1633
+ // prefetch logs "0/N NOT pruning" but bodies never
1634
+ // download. With C123 the fast lane has its own
1635
+ // independent client, so wrapping sync in slow-lane
1636
+ // withConnection doesn't block click-time body fetches.
1637
+ await this.withConnection(accountId, async (client) => {
1638
+ clientForDiag = client;
1639
+ await this.syncFolder(accountId, folder.id, client);
1640
+ }, { slow: true, timeoutMs: PER_FOLDER_TIMEOUT_MS });
1632
1641
  } catch (e: any) {
1642
+ // C120: per-folder timeout error appends transport
1643
+ // diagnostics so the [sync] log distinguishes "server
1644
+ // stopped responding" (sinceLastRead high) from "we
1645
+ // never finished writing" (writes climbing without
1646
+ // reads). The withConnection timeout already includes
1647
+ // its own message; we annotate further only for the
1648
+ // timeout path.
1649
+ if (/timeout/i.test(e?.message || "")) {
1650
+ const d = (clientForDiag as any)?.transport?.diagnostics;
1651
+ if (d) {
1652
+ 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}`;
1653
+ }
1654
+ }
1633
1655
  if (e.responseText?.includes("doesn't exist")) {
1634
1656
  this.db.deleteFolder(folder.id);
1635
1657
  } else {
@@ -2444,6 +2466,15 @@ export class ImapManager extends EventEmitter {
2444
2466
  * too and the per-account `prefetchingAccounts` set deduplicates. */
2445
2467
  async prefetchBodies(accountId: string): Promise<void> {
2446
2468
  if (this.prefetchingAccounts.has(accountId)) return;
2469
+ // Skip if the account isn't registered yet — the reconciler tick
2470
+ // can fire 2 s after daemon start, and addAccount may still be
2471
+ // running its OAuth flow. Without this guard we hit
2472
+ // ERROR_BUDGET (20) failures with "No config for account X" and
2473
+ // exit prefetch silently for the rest of the session — bodies
2474
+ // never download. Symptom: log shows 20 lines of `[prefetch] X
2475
+ // folder Y chunk 0: batch fetch failed: No config for account X`
2476
+ // followed by `stopping after 20 errors`.
2477
+ if (!this.configs.has(accountId)) return;
2447
2478
  this.prefetchingAccounts.add(accountId);
2448
2479
  try { await this._prefetchBodies(accountId); }
2449
2480
  finally { this.prefetchingAccounts.delete(accountId); }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.29",
3
+ "version": "0.1.31",
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.31",
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.31",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",