Skip to content

Stop concurrent requests from running in each other's mailbox - #47

Merged
odrobnik merged 2 commits into
mainfrom
claude/imap-connection-race
Oct 7, 2026
Merged

odrobnik merged 2 commits into
mainfrom
claude/imap-connection-race

Conversation

@odrobnik

@odrobnik odrobnik commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

Problem

Every tool selects a mailbox and then runs its commands, and all requests for an account share one IMAP connection. SwiftMail's command queue serializes single commands, not the select-plus-command pair, so overlapping requests interfere: one request's SELECT lands between another's SELECT and SEARCH.

Reproduced on 1.9.0 by starting five post search calls at the same time, four on one account in different mailboxes and one on a second account:

  • the Archive and Sent searches both returned exactly the INBOX search's UIDs
  • a search in a two-message folder returned 3 UIDs
  • the search on the other account was always correct
  • in one round a search never returned

The same race lets post fetch read a UID from the wrong mailbox, and lets move, trash, junk, flag or expunge act on the same UIDs in another mailbox.

Fix

  • withServer holds a per-account AsyncSerialLock for the whole operation (select, commands, reconnect retry), so the primary connection runs one request at a time.
  • searchMessages, countMessages, downloadEml and fetchRawMessages run through withExtraConnection, which leases one of two extra connections per account (IMAPConnectionManager.extraConnectionsPerServer, SwiftMail named connections post-extra-0/-1). Each serves one request at a time, and one-at-a-time use only opens the first. If the server refuses an extra connection, the request runs on the primary connection under the lock; a dropped extra connection is reconnected once.
  • MailboxCommandRunning is a small protocol that IMAPServer and IMAPNamedConnection both conform to, so the same code runs on either.
  • Search results fetch header data only (fetchMessageInfosBulk, in chunks of 200) instead of downloading every hit's full message. The output is unchanged.
  • Documentation/Daemon.md describes the connections per server.

Tests

  • New unit tests for AsyncSerialLock and ConnectionSlotPool. swift test: 57 tests, 0 failures.
  • ConcurrentMailboxAccessLiveTests (read-only; runs only with POST_LIVE_TEST_SERVER=<server id>, optionally POST_LIVE_TEST_MAILBOXES): a serial baseline, then three rounds of search, count, raw download and listing on four mailboxes at once. Passed 5 of 5 runs against a real account in about 13 s each, also after rebasing onto SwiftMail 1.14. On the code before this change it fails: a concurrent search hits a server-side FETCH error.

Not in this PR

  • countMessages over a whole large mailbox (60k+ messages) fails with PayloadTooLargeError: the ESEARCH ALL reply exceeds SwiftMail's 1 MB response buffer. Same on main; the live test limits its counts to the last year.
  • listMessages (post list) and fetchMessage still download complete messages; listing could use the header-only fetch as well.

🤖 Generated with Claude Code

Every tool selects a mailbox and then runs its commands, and all
requests for an account shared one IMAP connection. SwiftMail
serializes single commands, not the select-plus-command pair, so when
requests overlapped, one request's SELECT could land between another's
SELECT and SEARCH: searches returned another mailbox's results or
failed, UIDs were reported under the wrong mailbox, and move, trash or
expunge could act on the same UIDs in the wrong mailbox.

withServer now holds a per-account lock for the whole operation, so
the primary connection runs one request at a time.

Searches, counts and raw downloads (post search, post fetch --eml)
instead lease one of two extra connections per account (SwiftMail
named connections), one request per connection, so they neither wait
for nor disturb the primary connection. If the server refuses an extra
connection they run on the primary one.

Search results now fetch header data only instead of every hit's full
message.

ConcurrentMailboxAccessLiveTests compares concurrent requests with the
same requests run one at a time on a real account; it runs only with
POST_LIVE_TEST_SERVER set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-07T15:46:06.722418Z ca636e3 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ca636e3ce4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/PostServer/PostServer.swift Outdated
The primary-connection locks and extra-connection leases lived in the
IMAPConnectionManager, which reloadConfiguration() replaces. A request
holding the old manager's lock could then run on the new manager's
connection while a request after the reload took the new manager's
lock, so the two could interleave SELECTs on the same connection again.

PostServer now owns the locks and leases, so they survive the switch.
Operations still use whichever manager is current for connections; a
connection the reload closes under a running operation is reconnected
on the new manager while the operation keeps its lock or lease. The
switch itself moved into replaceConnectionManager(configuration:), which
the new tests use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@odrobnik
odrobnik merged commit 0489980 into main Oct 7, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant