Skip to content

COR-211, COR-212, COR-213, COR-216 - Fix IMAP login and connection lifetime crashes - #113

Merged
dbezverkhnii merged 9 commits into
spark2from
fix/COR-211-imap-login-postcondition
Sep 15, 2026
Merged

dbezverkhnii merged 9 commits into
spark2from
fix/COR-211-imap-login-postcondition

Conversation

@dbezverkhnii

@dbezverkhnii dbezverkhnii commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

Spark iOS 3.19.29 (first build on 2.1.55) got three new crash groups, each on the connection's queue thread at libetpan's first dereference of a NULL mailimap*: mailimap_expunge, mailimap_namespace (inside IMAPSession::login), search_modseq. A fourth, older group spiked with it: IMAPOperationQueueCallback::queueStartRunning() on a freed IMAPAsyncSession.

Why it happened. login() had no postcondition. identity() is the one step whose error it ignores, and identity() begins with connectIfNeeded(): a mShouldDisconnect raised meanwhile makes it tear the connection down and rebuild it (or fail to), and login() still reported success; selectIfNeeded() then skips SELECT for a session that is CONNECTED or DISCONNECTED and the command runs on a NULL mailimap. 5f27dfd (2.1.55) made interruptCurrentCommand() raise that flag asynchronously from the interrupting thread, which turned a rare conjunction (an ignored PARSE error in COMPRESS, no NAMESPACE, a failed reconnect) into a per-interrupt race against the client's 2-second grace interrupt.

What changes, one commit each:

  • disconnectOperation() retains the owner like every other operation — the queue's own retain of the owner is released in queueStoppedRunning() right before startThread() restarts for a queued operation.
  • The flag raise in interruptCurrentCommand() is removed. It is redundant since COR-205 — every command wrapper raises the flag on the stream error the cut command fails with — and it broke the invariant that the flag is written by the session's thread together with an error its caller checks. Cost: an interrupt landing with no command on the wire leaves the flag down, so the next command fails once before the reconnect.
  • login() requires STATE_LOGGEDIN before reporting success; otherwise ErrorConnection, which the client retries. This closes the pre-2.1.55 route too.
  • MCOIMAPAsyncConnection.deinit returns its lease on the session's dispatch queue instead of inline: a release with disconnect starts an operation, and that was the only start not coming from the session's queue.

Tests. IMAPConnectionOwnerLifetimeTests reproduces the owner use-after-free deterministically (ASan without the retain). IMAPLoginTests raises the flag while NAMESPACE is on the wire through a new scheduleReconnect() hook — the flag without the stream cut — and asserts ErrorConnection (without the postcondition: ErrorExpunge on an unauthenticated connection). Both were run failing-then-passing. xcrun swift test --sanitize=address over the IMAP suites is green; the 43 failures in the legacy unittest are pre-existing.

Alternative considered. Keeping the flag raise and skipping identity() when it is set would preserve needsReconnect() for an interrupt landing after an operation's last command, at the price of keeping an asynchronous writer of the flag. The postcondition stays as the backstop either way; the raise can be reintroduced on the operation boundary later if that window matters in practice.

🤖 Generated with Claude Code


Note

High Risk
Changes core IMAP login, interrupt/reconnect, and async session lifetime semantics that previously caused NULL mailimap and freed-owner crashes in production.

Overview
Fixes IMAP crashes from reporting login success on an unauthenticated or torn-down connection, use-after-free when a queued disconnect outlives the session, and unsafe lease release off the session queue.

login() now fails with ErrorConnection unless the session ends in STATE_LOGGEDIN without mShouldDisconnect—covering cases where identity()’s ignored connectIfNeeded() rebuilt the socket mid-login.

interruptCurrentCommand() no longer sets mShouldDisconnect from the interrupt thread (avoids racing login()’s multi-step flow); reconnect is driven by the cut command’s stream error, with docs noting the extra fail-then-rebuild edge when nothing is on the wire. scheduleReconnect() is exposed (C/Swift/tests) to raise the flag between commands without cancelling the stream.

disconnectOperation() retains the async session owner like other operations so a queued disconnect still runs after the pool drops the last session reference. MCOIMAPAsyncConnection.deinit asyncs lease release onto the session dispatch queue. Tests add IMAPConnectionOwnerLifetimeTests, IMAPLoginTests, and LeaseTestTCPEndpoint.beforeAnswering.

Reviewed by Cursor Bugbot for commit 35710cc. Bugbot is set up for automated code reviews on this repo. Configure here.

dbezverkhnii and others added 4 commits September 15, 2026 19:47
…-216

IMAPAsyncConnection::disconnectOperation() retained the connection but not
its owner, unlike every operation the owner's factories create. The queue
thread's retain of the owner is released in queueStoppedRunning() before
stoppedOnMainThread() restarts the thread for an operation queued while it
was quitting, so a queued disconnect could restart it under a freed
IMAPAsyncSession - the queueStartRunning() crash group (43 events in iOS
3.19.26, 9 in 3.19.29).

The test reproduces it under AddressSanitizer without the retain.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
interruptCurrentCommand() raised mShouldDisconnect from the interrupting
thread (5f27dfd). Raised while login() is between two of its commands,
the flag is met by the connectIfNeeded() inside identity(), whose result
login() ignores: the connection is torn down and rebuilt - or not - under
a caller that then reports success. That is how iOS 3.19.29 got its
mailimap_expunge, mailimap_namespace and search_modseq crashes on a NULL
mailimap (COR-211, COR-212, COR-213).

The line is redundant since COR-205: every command wrapper raises the flag
on the stream error the cut command fails with, at a point its caller
checks. Leaving the flag down when an interrupt lands with no command on
the wire costs one failed command before the reconnect.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
login() ignores identity()'s error, and identity() starts with
connectIfNeeded(): with mShouldDisconnect raised meanwhile it tears the
connection down and rebuilds it, and login() still returned ErrorNone -
with the session CONNECTED but not authenticated, or with no mailimap at
all when the rebuild failed. selectIfNeeded() then skips SELECT for such a
state and the command runs on a NULL mailimap (COR-211, COR-212, COR-213).
login() now requires STATE_LOGGEDIN before reporting success.

The test raises the flag while NAMESPACE is on the wire, through a
scheduleReconnect() hook on the connection that sets the flag without
cutting the stream; declared last in the exported classes to keep their
vtable layout.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
MCOIMAPAsyncConnection.deinit released its lease inline, on whatever thread
dropped the handle, and a release with disconnect starts an operation.
Every operation start on a session has to come from its dispatch queue:
OperationQueue::startThread() and IMAPAsyncConnection::runOperation() guard
their state by that queue alone, and a start from elsewhere can run two
queue threads on one connection or over-release the owner. The release now
hops to the session's queue; the lease is back in the pool once that queue
has run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@dbezverkhnii

Copy link
Copy Markdown
Collaborator Author

bugbot run

@cursor cursor 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.

Stale Bugbot comment from a previous run.

Copilot AI 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.

🟡 Changes recommended

The owner-lifetime test is missing its required Darwin import, and three documentation comments need clarification.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Fixes IMAP login false-success crashes and asynchronous connection owner lifetime issues.

Changes:

  • Validates login state and removes interrupt-thread reconnect flag writes.
  • Retains owners for queued disconnects and queues lease cleanup.
  • Adds reconnect hooks and regression tests.
File summaries
File Summary
unittest/LeaseTestTCPEndpoint.swift Adds command synchronization hooks.
unittest/IMAPLoginTests.swift Tests login failure after reconnect.
unittest/IMAPConnectionOwnerLifetimeTests.swift Tests queued disconnect owner lifetime; requires an explicit Darwin import for usleep.
unittest/IMAPConnectionLeaseTests.swift Adjusts asynchronous lease expectations.
src/swift/imap/IMAPSession.swift Adds internal lease-release support.
src/swift/imap/IMAPBaseOperation.swift Updates interrupt behavior documentation; should qualify the no-command-on-wire exception.
src/swift/imap/IMAPAsyncConnection.swift Queues deinitialization cleanup and adds reconnect support.
src/include/MailCore/MCIMAPSession.h Declares reconnect scheduling.
src/include/MailCore/MCIMAPOperation.h Updates interrupt documentation; should qualify the reconnect exception.
src/include/MailCore/MCIMAPAsyncConnection.h Declares connection reconnect support.
src/include/MailCore/CIMAPAsyncConnection.h Exposes reconnect scheduling through C bindings.
src/core/imap/MCIMAPSession.h Adds reconnect API and state documentation.
src/core/imap/MCIMAPSession.cpp Validates login state and changes interrupt handling.
src/c/imap/CIMAPAsyncConnection.h Updates C wrapper declarations.
src/c/imap/CIMAPAsyncConnection.cpp Implements the C reconnect wrapper.
src/async/imap/MCIMAPOperation.h Updates interrupt documentation; should qualify the reconnect exception.
src/async/imap/MCIMAPAsyncConnection.h Declares asynchronous reconnect support.
src/async/imap/MCIMAPAsyncConnection.cpp Retains owners for disconnect operations.
Package.swift Registers the new tests.
Review details

Suppressed comments (3)

src/async/imap/MCIMAPOperation.h:56

  • The implementation intentionally leaves mShouldDisconnect unset when the interrupt lands after the command has finished; in that case the cancelled stream is used by the next command, which fails once before a later reconnect. This new header wording promises an unconditional rebuild before the next command and contradicts the documented race/result case below; qualify it with that exception.
         Teardown of this connection only - the cut command fails with a connection error and the
         connection is rebuilt before the one after - so call it for a command that is being
         abandoned (cancelled, or given up on), never to hurry up a command whose result still
         matters.

src/include/MailCore/MCIMAPOperation.h:56

  • The implementation intentionally leaves mShouldDisconnect unset when the interrupt lands after the command has finished; in that case the cancelled stream is used by the next command, which fails once before a later reconnect. This new public-header wording promises an unconditional rebuild before the next command and contradicts the documented race/result case below; qualify it with that exception.
         Teardown of this connection only - the cut command fails with a connection error and the
         connection is rebuilt before the one after - so call it for a command that is being
         abandoned (cancelled, or given up on), never to hurry up a command whose result still
         matters.

src/swift/imap/IMAPBaseOperation.swift:39

  • The implementation intentionally leaves mShouldDisconnect unset when the interrupt lands after the command has finished; in that case the cancelled stream is used by the next command, which fails once before a later reconnect. This wording promises an unconditional rebuild before the next command and contradicts the documented race/result case below; qualify it with that exception.
     reaches the command already in flight. It costs the connection: the cut command fails with a
     connection error and the connection is rebuilt before the one after, so call it for a command
     being abandoned, never to hurry up one whose result still matters.
  • Files reviewed: 19/19 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread unittest/IMAPConnectionOwnerLifetimeTests.swift
dbezverkhnii and others added 2 commits September 15, 2026 19:59
Foundation re-exports it on Apple platforms, so the file compiled, but the
sibling tests spell the dependency out and this one now does too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…OR-211

The wording promised a rebuild before the next command unconditionally;
with the reconnect flag raised by the failing command rather than by the
interrupt, an interrupt that lands between commands costs that next command
first.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@dbezverkhnii
dbezverkhnii requested a lite review from Copilot September 15, 2026 17:21
@dbezverkhnii

Copy link
Copy Markdown
Collaborator Author

bugbot run

@cursor cursor 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.

Stale Bugbot comment from a previous run.

Copilot AI 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.

🟡 Changes recommended

Unresolved critical and moderate findings include a missing Swift import, a required Windows artifact, and reconnect/teardown race concerns.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (2)

src/core/imap/MCIMAPSession.cpp:4445

  • scheduleReconnect() is documented and used from another thread, but connectIfNeeded() clears mShouldDisconnect only after the potentially long disconnect(). If another schedule arrives during that teardown, the later unconditional clear erases it, so the following command can run without honoring the new request. Clear the flag with an atomic exchange(false) before teardown (or otherwise preserve concurrent writes) so a schedule during disconnect remains pending.
    mShouldDisconnect = true;

src/swift/imap/IMAPAsyncConnection.swift:52

  • This teardown is queued on the session's current dispatch queue, but existing connections copy the session queue only when they are created (src/async/imap/MCIMAPAsyncSession.cpp:298-300); changing MCOIMAPSession.dispatchQueue later does not update the connection's operation queue (src/async/imap/MCIMAPAsyncSession.cpp:1008-1017). If the queue is changed while this lease is alive, deinit can start the disconnect from a different queue than the connection's queued callbacks/timer, racing another start/stop. Capture the connection's queue for this lease (or update existing connections when the session queue changes) before hopping the release.
        (session.dispatchQueue ?? DispatchQueue.main).async {
            session.releaseConnection(connection, leaseGeneration: leaseGeneration, disconnect: true)
  • Files reviewed: 19/19 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment thread src/core/imap/MCIMAPSession.cpp
Comment thread src/swift/imap/IMAPAsyncConnection.swift
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@dbezverkhnii
dbezverkhnii requested a lite review from Copilot September 15, 2026 17:33
@dbezverkhnii

Copy link
Copy Markdown
Collaborator Author

bugbot run

@cursor cursor 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.

Stale Bugbot comment from a previous run.

Copilot AI 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.

🔵 Needs a closer look

Unresolved findings remain in login correctness, Swift 5.4 test compatibility, and queue serialization across high-risk IMAP threading and lifetime changes.

Review details

Suppressed comments (2)

src/include/MailCore/MCIMAPSession.h:265

  • The new contract says scheduleReconnect() is “between operations only”, but the implementation and IMAPLoginTests intentionally call it while NAMESPACE is on the wire and rely on the next connectIfNeeded() in that same login operation. Please clarify this public/source-duplicate documentation to state whether cross-thread calls during an operation are supported; as written, callers cannot reconcile the documented restriction with the tested behavior.
        // Any thread, but between operations only: raised while one runs, it is met by the next
        // connectIfNeeded() inside that operation - login() then fails with ErrorConnection.

src/swift/imap/IMAPAsyncConnection.swift:55

  • This hops to the session's current dispatch queue, but existing connections keep the queue they were created with (IMAPAsyncSession::session() copies mDispatchQueue into the connection and later session queue changes do not update them). If dispatchQueue is changed after this lease is acquired, deinit starts releaseConnection on a different queue from the connection's operation queue, violating the acquire/release serialization contract and racing queue state. Capture/use the connection's queue (or explicitly make the session queue immutable after connections are created).
        (session.dispatchQueue ?? DispatchQueue.main).async {
            session.releaseConnection(connection, leaseGeneration: leaseGeneration, disconnect: true)
            connection.release()
        }
  • Files reviewed: 19/19 changed files
  • Comments generated: 3
  • Review effort level: Lite

Comment thread src/core/imap/MCIMAPSession.cpp Outdated
Comment thread unittest/IMAPLoginTests.swift Outdated
Comment thread unittest/IMAPLoginTests.swift Outdated
dbezverkhnii and others added 2 commits September 15, 2026 20:51
…n COR-211

A stream or parse error inside identity() keeps the state at LOGGEDIN but
raises mShouldDisconnect; the next command would fail on it anyway, so
login() reports the connection error itself instead of a success on a
stream that has to be rebuilt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Explicit lock()/unlock() instead of withLock, and no Sendable conformance:
the slot is only ever handed to a non-Sendable closure.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@dbezverkhnii
dbezverkhnii requested a lite review from Copilot September 15, 2026 17:51
@dbezverkhnii

Copy link
Copy Markdown
Collaborator Author

bugbot run

@cursor cursor 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.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 35710cc. Configure here.

Copilot AI 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.

🟡 Changes recommended

Two critical issues remain around login failure propagation and queue-correct lease cleanup.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 19/19 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment thread src/core/imap/MCIMAPSession.cpp
Comment thread src/swift/imap/IMAPAsyncConnection.swift
@dbezverkhnii
dbezverkhnii merged commit 4822f34 into spark2 Sep 15, 2026
10 of 11 checks passed
@dbezverkhnii
dbezverkhnii deleted the fix/COR-211-imap-login-postcondition branch September 15, 2026 18:17
dbezverkhnii added a commit that referenced this pull request Sep 15, 2026
The extra mStreamCancelled term only moved an ErrorConnection one command
earlier for an operation that is being abandoned, and no deterministic test
can reach it: a cut between two of login()'s commands is met by the next
command's write.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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.

2 participants