[PM-38024] fix: Retain cycle in DefaultTOTPExpirationManager#2699
[PM-38024] fix: Retain cycle in DefaultTOTPExpirationManager#2699amanjeetsingh150 wants to merge 1 commit into
Conversation
The Timer scheduled in init captured self strongly via
{ _ in self.checkForExpirations() }. The cycle
(Manager -> updateTimer -> Timer -> block -> self) prevented deinit
from running, so cleanup() (which invalidates the Timer) was never
called, leaving every instance alive for the process lifetime.
Add [weak self] to the Timer's block. With the cycle broken, deinit
runs normally and cleanup() invalidates the Timer automatically.
Includes a weak-reference regression test that asserts the manager
deallocates after the caller drops its strong reference.
Closes bitwarden#2698
|
|
|
Thank you for your contribution! We've added this to our internal tracking system for review. Details on our contribution process can be found here: https://contributing.bitwarden.com/contributing/pull-requests/community-pr-process. |
KatherineInCode
left a comment
There was a problem hiding this comment.
Thank you for your contribution! This is a nice thing to catch!
In addition to my inline comments, I have two more general ones:
- Something went wrong with the CLA bot; can you please re-check and make sure that you've signed it and all that is squared away?
- We have two
DefaultTOTPExpirationManagerclasses that duplicate this code, but this fixes the retain cycle in only one of them. Can you please replicate this and/or find a way to consolidate that object intoBitwardenKit?
Thanks!
|
|
||
| @testable import BitwardenShared | ||
|
|
||
| class TOTPExpirationManagerLeakTests: BitwardenTestCase { |
There was a problem hiding this comment.
🎨 We are trying to write all new tests in Swift Testing. Can you please convert this?
| @testable import BitwardenShared | ||
|
|
||
| class TOTPExpirationManagerLeakTests: BitwardenTestCase { | ||
| /// Regression test for the retain cycle in `DefaultTOTPExpirationManager` |
There was a problem hiding this comment.
🎨 This documentation comment is verbose, and not in line with our usual test documentation comments. If there's further context necessary, it can be a comment inside the test function, though I also think a simple test documentation comment will suffice for explaining what is being tested.
| } | ||
|
|
||
| // Let any in-flight work settle so deinit (if it could fire) would have. | ||
| try await Task.sleep(nanoseconds: 200_000_000) |
There was a problem hiding this comment.
🤔 Is this sleep really necessary? If so, does it have to be 0.2 seconds? As well, it appears that elsewhere in our codebase we document the ns->s conversion, can you please match that convention?
| XCTAssertNil( | ||
| weakManager, | ||
| """ | ||
| DefaultTOTPExpirationManager should deallocate after the caller \ |
There was a problem hiding this comment.
🎨 This feels overly verbose to me; most of the time we don't include a specific failure message, and when we do, it's a terse explanation of what failed
🎟️ Tracking
Closes #2698
📔 Objective
DefaultTOTPExpirationManager.initschedules a repeatingTimerwhose block capturesselfstrongly. The cycle (Manager → updateTimer → Timer → block → self) preventsdeinitfrom running, which meanscleanup()(the method that invalidates the Timer) is never called. Every instance ever constructed stays alive for the process lifetime.VaultGroupProcessor.deinitalready callscleanup()ongroupTotpExpirationManageras a workaround for this exact issue, suggesting prior awareness. The workaround is missing forsearchTotpExpirationManager, and stops the timer without releasing the manager instance itself — symptom, not cause.Full bug analysis, impact, and rationale for choosing this fix over a structural refactor are in #2698.
Fix
Add
[weak self]to the Timer block:Non-breaking, no caller migration. With the cycle broken,
deinitruns normally andcleanup()invalidates the Timer automatically. The existingVaultGroupProcessorworkaround becomes defensive rather than load-bearing.Regression test
Added
TOTPExpirationManagerLeakTests.test_deallocatesAfterCallerReleasesReference. Uses a weak reference to assert the manager deallocates after its only strong reference is dropped —leaks(1)-based audits don't catch this class of bug because the Timer is held by the main RunLoop, keeping the cycle reachable from a process root. The weak-reference approach catches it cleanly.Test fails on
mainwithXCTAssertNil failed: "BitwardenShared.DefaultTOTPExpirationManager"; passes with the fix.📸 Screenshots
n/a — no UI changes.