COR-221 - Wait out a running IDLE before freeing its stream - #116
Conversation
Cherry-pick of upstream 03a1947 "Fix IMAP IDLE teardown races", adapted to this fork. setupIdle(), interruptIdle() and unsetupIdle() dereferenced mImap->imap_stream guarded only by mIdleEnabled, and idle() read the stream with no lock at all before blocking in mailstream_wait_idle() on it - so a teardown running meanwhile closed and freed the stream under the thread still using it. mIdleInProgress plus a condition variable close both: idle() takes a local mailimap* under the lock and raises the flag, teardown interrupts the idle and waits for the flag to drop before freeing anything, and the three main-thread entry points get their NULL checks. The fork locks through the MCB_* shim rather than raw pthread calls, so upstream's pthread_cond_* usage arrives as new MCB_COND_* macros in MCBasicLock.h, mapping to CONDITION_VARIABLE on Windows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Address the setup state, Windows artifact, and test timeout issues.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Ports the upstream IMAP IDLE teardown-race fix, coordinating stream lifetime, teardown, and cancellation.
Changes:
- Adds condition-variable synchronization for in-progress IDLE operations.
- Interrupts active IDLE operations on cancellation.
- Expands Swift cancellation and stress-test coverage.
File summaries
| File | Reviewed change | Final review note |
|---|---|---|
unittest/IMAPIdleCancellationTests.swift |
Updates IDLE cancellation tests and wait budgets. | Increase the outer timeout. Moderate, 3 votes. |
src/include/MailCore/MCIMAPSession.h |
Declares IDLE synchronization state. | No final comment. |
src/include/MailCore/MCIMAPIdleOperation.h |
Declares cancellation support. | No final comment. |
src/include/MailCore/MCBasicLock.h |
Adds condition-variable macros. | Publish the matching Windows prebuilt archive. Moderate, 1 vote. |
src/core/imap/MCIMAPSession.h |
Adds internal synchronization declarations. | No final comment. |
src/core/imap/MCIMAPSession.cpp |
Coordinates IDLE execution and teardown. | No final comment. |
src/core/basetypes/MCBasicLock.h |
Adds internal condition-variable macros. | Publish the matching Windows prebuilt archive. Moderate, 1 vote. |
src/async/imap/MCIMAPIdleOperation.h |
Updates async operation declarations. | No final comment. |
src/async/imap/MCIMAPIdleOperation.cpp |
Implements cancellation interruption and locking. | Clear mSetupSuccess during unprepare(). Moderate, 1 vote. |
Review details
Suppressed comments (4)
src/async/imap/MCIMAPIdleOperation.cpp:51
mSetupSuccessremains true afterunprepare()completes, so cancelling a retained, already-finished idle operation still entersinterruptIdle()and can interrupt a later IDLE on the same connection. Clear the setup-success state as part of unprepare (before callingunsetupIdle()) so cancellation only targets the operation's active setup.
if (setupSuccess()) {
src/core/basetypes/MCBasicLock.h:20
- These C/C++ changes are included in the Windows source digest, so they require a matching
mailcore2-windows-<digest>.zipon the permanentwindows-prebuiltrelease. Without publishing that archive before merge, the Windows prebuilt check and Spark's Windows build will fail even though the source compiles elsewhere.
#define MCB_COND_TYPE CONDITION_VARIABLE
#define MCB_COND_INIT(c) InitializeConditionVariable(c)
#define MCB_COND_DESTROY(c)
/* 0 = the lock is held exclusively, which is how MCB_LOCK takes it. */
#define MCB_COND_WAIT(c, l) SleepConditionVariableSRW(c, l, INFINITE, 0)
unittest/IMAPIdleCancellationTests.swift:347
- This 20-second gate plus the later 1s, 5s, and 5s waits can take 31 seconds, but the enclosing
runOffMainThread(timeout: 30)still expires at 30 seconds. A slow but successful cancellation test can therefore fail due only to the wrapper timeout; increase that outer budget.
XCTAssertTrue(server.waitForIdleEntered(timeout: 20), "The session was expected to enter IDLE. Client sent:\n\(server.transcript)")
unittest/IMAPIdleCancellationTests.swift:376
- The new gate can consume 20 seconds and the queue-stop wait another 10 seconds, while this body is still wrapped in
runOffMainThread(timeout: 30). That leaves no scheduling margin and can make a valid iteration time out; use a larger outer timeout for this stress case.
XCTAssertTrue(server.waitForIdleEntered(timeout: 20),
- Files reviewed: 9/9 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.
Raising the waitForIdleEntered precondition to 20s left the enclosing runOffMainThread() at 30s, which the sum of the waits inside a body can now exceed - so a merely slow but correct run would have failed on the wrapper instead of on its own assertion, trading one flake for another. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks — one accepted, one declined, one already planned. Test timeout arithmetic — fixed in 8b0bf9f. Correct arithmetic and my bug, not upstream's: raising Clearing It is also not reachable from this repo's consumer. Spark's This fork is 572 commits ahead of its upstream merge base with a large merge pending, so a deviation from Windows prebuilt — already on the plan. Correct, and the red |
There was a problem hiding this comment.
🔵 Needs a closer look
Cancellation can be lost during setup, allowing a canceled operation to enter blocking IDLE.
Review details
Suppressed comments (1)
src/async/imap/MCIMAPIdleOperation.cpp:46
- Cancellation can still be lost in this handoff:
isInterrupted()releasesmLockbeforesetupIdle()completes andsetSetupSuccess()records the result. Ifcancel()lands in that window, it observesmSetupSuccess == falseand sends no interrupt;main()then observes setup success and enters the blockingidle()despite the cancellation. Make the cancellation/setup transition atomic, or re-check cancellation after prepare and undo the setup before returning.
setSetupSuccess(session()->session()->setupIdle());
- Files reviewed: 9/9 changed files
- Comments generated: 0 new
- Review effort level: Lite
|
Declining this one too, for the same reason as the The window is real and correctly described: if (isInterrupted()) { mSetupSuccess = false; return; }
mSetupSuccess = session()->session()->setupIdle();with Worth comparing the two sides for the case you describe, a cancel arriving around setup:
So the port takes it from "always" to "in a narrow race". Closing the race as well means either holding It is already listed under "known gaps" in the description, and it is now tracked with the CI is green on |
Cherry-pick of upstream
03a19472("Fix IMAP IDLE teardown races", by the mailcore maintainer), adapted to this fork. One of the 39 upstream commits we do not have; taking it now is also a down payment on the pending large upstream merge.What was wrong
Two defects, both the shape we closed elsewhere in COR-211/212/213 and COR-216, just in the IDLE path:
setupIdle(),interruptIdle()andunsetupIdle()dereferencedmImap->imap_streamguarded only bymIdleEnabled.unsetup()does clearmIdleEnabledin the same critical section that nilsmImap, somIdleEnabled == truedid implymImap != NULL— but not thatimap_stream != NULL, which libetpan nils on logout and on some stream errors.idle()readmImap->imap_streamunder no lock and then blocked inmailstream_wait_idle()on it, while a teardown on another thread closed andmailimap_freed it. Use-after-free.What the port does
mIdleInProgressplus a condition variable.idle()takes a localmailimap *under the lock and raises the flag;unsetup()andunsetupIdle()interrupt the running idle and then wait for the flag to drop before freeing anything; the three main-thread entry points get their NULL checks.IMAPIdleOperationgains acancel()override that callsinterruptIdle(), and itsmSetupSuccessbecomes lock-protected.cancel()is an override of the already-virtualOperation::cancel(), so no vtable slot is added andinterruptIdle()'s index is unchanged.Fork adaptations
MCB_*shim rather than raw pthread calls, so upstream'spthread_cond_*arrives as newMCB_COND_*macros inMCBasicLock.h:CONDITION_VARIABLE+SleepConditionVariableSRW(c, l, INFINITE, 0)on_MSC_VER(flags0= the SRWLOCK is held exclusively, which is howMCB_LOCKtakes it),pthread_cond_*elsewhere.MCB_COND_DESTROYis empty on Windows, mirroring the existing emptyMCB_LOCK_DESTROY.unsetup()changes from COR-217/COR-218 unchanged:mStreamCancelledcleared andmState = STATE_DISCONNECTEDpublished inside the lock, close and free after the unlock. The new wait sits ahead of all of it.ab53363bis a 634-line C++ file intests/, which exists because upstream has no Swift tests; our CMaketestsexecutable is not what CI runs, so it was not copied. Its third goal — "does not crash or hang under repetition, especially under ASan" — is covered by a Swift test instead.interruptCurrentCommand()'s comment claimed holdingmIdleLockis a bounded wait. That is no longer true, so the comment now says so.Tests
testCancelAllOperationsWakesRunningIdlewas carrying anXCTExpectFailurewaiting for exactly this commit; the expectation is gone and the assertion stands (and is strictly stronger —waitForDoneOrClosewas previously only reached conditionally).New
testRepeatedCancelDuringIdleDoesNotHangOrCrashruns the connect/idle/cancel/tear-down cycle six times. It gates on the fake server signalling+ idlingrather than on a sleep, so a cancel that arrives before IDLE is established — which the operation queue simply drops, proving nothing — cannot make it pass vacuously.Verified: 6/6 iterations fail against the pre-fix sources (the queue never stops, 60s of hung iterations) and pass in 6.3s with the fix. All 33 IMAP tests pass on macOS, and again under
--sanitize=addresswith no sanitizer reports.Two
waitForIdleEnteredpreconditions went 5s → 20s. The iOS job failed once on that budget already (connect + CAPABILITY + LOGIN + LIST + SELECT + IDLE inside 5s on a loaded runner); the waits that carry the property under test are untouched at 1s and 5s.Known gaps, deliberately not addressed here
idle()— needs the synchronousIMAPSessiondriven from two threads, which the Swift layer does not expose. Everything shipping here exercises defect 1. Reaching the newmIdleCondmachinery from a test would mean adding another test hook to the production surface; not worth it in this change.unsetupIdle()issues the interrupt but notmailstream_cancel, unlikeunsetup(). If it ever ran with an idle in flight blocked insidemailimap_idle(), it would spin until the socket timeout — on a method documented "main thread". Unreachable today: its only caller isIMAPIdleOperation::unprepare, which runs afteridle()returned. Left as upstream wrote it to keep the merge clean.IMAPIdleOperation::prepare()dropsmLockbetween readingmInterruptedand writingmSetupSuccess, so acancel()landing in that window issues no interrupt andmain()never re-checks. Present identically upstream; out of scope for a port.🤖 Generated with Claude Code
Note
High Risk
Changes cross-thread IMAP teardown and blocking waits on the idle lock, which can affect disconnect/cancel timing and previously had use-after-free risk on the live stream.
Overview
Fixes IMAP IDLE teardown races by tracking when
idle()is blocked on the socket and making disconnect/unsetup wait for that work to finish before closing the stream.IMAPSessionaddsmIdleInProgressand a condition variable (MCB_COND_*inMCBasicLock.h).idle()sets the flag undermIdleLock, runs IDLE on a localmailimap *, then clears the flag and broadcasts in a shared cleanup path.unsetup()andunsetupIdle()interrupt any in-flight IDLE andMCB_COND_WAITuntil the flag drops;setupIdle(),interruptIdle(), and related paths gainmImap/imap_streamnull checks and refuse overlapping IDLE setup.IMAPIdleOperationoverridescancel()to callinterruptIdle()socancelAllOperations()can wake a running IDLE;mSetupSuccessis read/written under its lock, andinterruptIdle()only calls into the session when setup succeeded andsession()is non-null.Swift tests drop the
XCTExpectFailureon cancel-waking-IDLE, add a six-iteration cancel-during-IDLE stress case, and relax some setup timeouts on slow CI.Reviewed by Cursor Bugbot for commit 8b0bf9f. Bugbot is set up for automated code reviews on this repo. Configure here.