Session State That Outlives the Logout
A shopping app uses SessionManager.shared, which holds currentUser and var cart: Cart. CartViewController registers a NotificationCenter observer with addObserver(forName:object:queue:using:), captures self strongly, and discards the returned token. After logout and login as a different account, the cart badge still shows the previous user's 3 items; Home recommendations are similarly stale because they are cached in a shared ImageCache plus a static view-model cache. The Cart screen also never deallocates: after 12 logout/login cycles the memory graph holds 12 CartViewControllers. A background refresh assigns viewModel.items from a URLSession completion and occasionally logs a main-thread checker warning.
1. Identify the root cause of the stale cart and recommendations, and contrast a `reset()` patch with a scoping fix.
Read the worked answer
The singleton's lifetime is the process's lifetime, and nothing in it is scoped to a login. logout() clears the auth token, but cart and everything hanging off shared survive, so the next user inherits them. The same shape of bug appears in the static view-model cache and the shared ImageCache: app-scoped storage holding session-scoped data, with no boundary at which it is discarded.
A reset() method patches today's symptom. The flaw is that reset is an enumeration that must stay complete forever: the twelfth field someone adds next quarter will be forgotten, and no test will notice, because nothing structurally prevents it.
Scoping fixes it structurally: create a Session object on successful login, holding the user, cart, and per-session caches; inject it into the view controllers or scenes that need it; on logout, release it, so deallocation is the reset. Anything genuinely global - a disk cache, analytics - must be keyed by user id rather than merely shared.
If a full refactor is out of scope, ship a reset() that takes the new user id and a test that logs in, populates the cart, logs out, logs in as someone else, and asserts an empty cart.
2. The background refresh occasionally mutates view-model state off the main thread. Explain the cause and the isolation rule you would enforce.
Read the worked answer
URLSession deliver its completion on a background delegate queue, so assigning viewModel.items there mutates observable state off the main thread and then drives a UI update from the wrong thread. Effects range from a runtime checker warning to inconsistent layout to a hard crash under load - the intermittent warning is the cheap early version of the same bug.
Make isolation explicit instead of sprinkling DispatchQueue.main.async at random call sites:
@MainActor final class CartViewModel: ObservableObject {
@Published private(set) var items: [CartItem] = []
func refresh() async throws {
let fetched = try await service.fetch() // suspended off the main actor
items = fetched // resumes on the MainActor
}
}
With async/await, resuming on the actor is automatic, and decoding stays off it if the service is not actor-isolated. For completion-handler code, hop once at the boundary with Task { @MainActor in … }, in the view model only.
Rule: exactly one owner declares the isolation, everything crossing into it is awaited, and the models it publishes are Sendable value types. Two layers each assuming the other hopped is how this bug keeps returning.