Waiting for logout before login still left a race inside the handshake
Correction 2026-10-31: The earlier version attributed the reported failure to an idle timer. Later source review ruled out that timer for this app. The injected-teardown reproduction and exclusion-gate fix remain valid; the production trigger is unknown.
Users authenticated with Face ID, and the login failed anyway with a generic “login failed” error. The password was right, and the biometric enrollment was fine. The server’s log for the affected user showed the last handshake step arriving on a session in its initial state, with no user bound to it.
The app waited for any teardown already running before starting login. A dedicated test injected a new teardown after that wait and reproduced the same server-state signature. Holding an exclusion gate for the whole handshake fixed that reproduction, but did not establish the cause of the reported incident.
Setup #
- A stateful, multi-step login (SRP-style): start a session, send the identity, send the proof, finish. The server tracks progress per session cookie.
- A session teardown: sign out on the server, then clear the local cookie jar.
begin()returns immediately, and concurrentbegin()calls join one teardown. - An earlier fix for a related bug: before its handshake, login called
waitForCompletion()so a sign-out still in flight couldn’t destroy the new session. - The reproduction explicitly calls
begin()during the handshake. It does not rely on an idle timer.
login: { credential in
await sessionTeardown.waitForCompletion() // once, before the handshake
return await client.login(credential) // four round trips
}
Reproduce with the real stack #
A dedicated live test drove the production login code against a development server and fired begin() at a fixed delay into the handshake. At 500 ms:
start -> {"state":"new"}
send identity -> {"state":"identified", "salt":...}
send proof (M1) -> {"state":"proof-verified", "M2":...}
logout() <- the teardown fires here
POST /logout
finish (keys sent)
Logout success
Session teardown: cookie jar cleared
Error: state "new"
-> failed(.other)
This matched the server-state signature logged for the user. In the reproduction, the one-time wait had nothing to wait for: no teardown was running when login started. The test started one between two round trips.
The trap inside the trap: which effect does the damage #
The code comments blamed the cookie-jar clear: the concluding step would go out without a session cookie and land on an anonymous session. That would suggest a small fix: defer only the local cleanup.
In the reproduction’s trace, the cookie jar was cleared after the finish step had already gone out with valid cookies. The server sign-out reset the half-authenticated session, and the concluding step then arrived at a session the server had put back to new. Deferring only the jar clear would still fail this exact trace.
Before you choose what to defer, read the ordering on the wire. The local effect you can see easily may not be the one that broke things.
Fix: hold exclusion for the whole protocol #
The teardown gets a gate. Login holds it from the first round trip to the last. A begin() that arrives while the gate is held is honored but deferred—neither its sign-out nor its cookie clear runs until the handshake releases the gate.
private struct TeardownState {
var inFlight: Task<Void, Never>?
var handshakes = 0 // a count, so overlapping logins can't release each other's gate
var isHeldBack = false
}
begin: {
state.withValue { s in
guard s.inFlight == nil else { return } // join the running one
guard s.handshakes == 0 else { s.isHeldBack = true; return }
s.inFlight = Task { await runTeardown() } // clears inFlight when done
}
},
beginHandshake: {
// Wait out a running teardown, then claim the gate. Loop, because the slot
// is only free on a later pass.
while let running = state.withValue({ s -> Task<Void, Never>? in
guard let t = s.inFlight else { s.handshakes += 1; return nil }
return t
}) {
await running.value
}
},
endHandshake: {
state.withValue { s in
s.handshakes -= 1
guard s.handshakes == 0, s.isHeldBack else { return }
s.isHeldBack = false
s.inFlight = Task { await runTeardown() }
}
}
func excludingTeardown<T>(_ body: () async -> T) async -> T {
await beginHandshake()
defer { endHandshake() }
return await body()
}
Login wraps every credential type in it, so a new login surface can’t skip it:
login: { credential in
await sessionTeardown.excludingTeardown {
await client.login(credential)
}
}
The other properties stayed the same. begin() still returns immediately. A second begin() still means one teardown, whether that teardown is running or held back. A teardown whose sign-out throws still releases its waiters. Logout still happens: it runs right after login, which then ends in a signed-out state on purpose.
Later source review found that this app created its idle timer only after the full login completed and dropped it when the login screen appeared. The shipped revision had the same wiring. The earlier claim that this timer disproved a signed-in-only assumption was withdrawn. The gate provides exclusion without depending on that reachability argument.
A sibling app did retain its timer after logout and needed a signed-in guard. That was a separate timer-lifetime defect, not evidence that the timer caused this app’s reported failure.
Verification #
- Unit test for the new direction. A teardown begun inside
excludingTeardownruns neither effect until the body returns. The expected order is[handshake, signOut, clearCookieJar]. Inside the body, the test awaitswaitForCompletion()rather thanTask.yield(), so a buggy teardown is guaranteed a chance to run. A yield would leave that to the scheduler. - Unit test for the old direction. A teardown already running still finishes before the handshake starts. That is the guarantee the earlier fix provided.
- Injected-teardown sweep. The reproduction fires the teardown at 100, 200, 300, 400, 500 and 700 ms. It had been marked
XCTExpectFailure. After the fix, every delay logged in:
teardown at 100ms -> loggedIn
teardown at 200ms -> loggedIn
...
teardown at 700ms -> loggedIn
The expected-failure marker was removed in the same commit.
What this doesn’t prove #
- The reproduction establishes a failure mechanism and verifies the exclusion fix. It does not prove that this mechanism caused the reported incident or identify its triggering call. We ruled out the idle timer for this app; the field trigger remained unknown.
- The dedicated reproduction and the unit tests verify the fix. The live test class still exited with failures. They came from a separate host-app dependency problem in the test process, and another test in that class still tracks an unrelated second-login bug. Don’t read “the sweep passes” as “the live suite is green”.
- The ordinary test plan passed, but live tests are opt-in there, so it says nothing about the server interaction.
- Which delays hit the window depends on latency to the server. A sweep is better than a single delay, but it is still a sample.
Takeaways #
- Waiting once for cleanup before a multi-step protocol protects only that instant. If cleanup can start during the protocol, exclude it for the whole protocol.
- Defer cleanup, don’t drop it. Queue the request and run it when the gate is released.
- Count holders instead of using a flag, so two overlapping logins can’t release the gate from under each other.
- Read the wire ordering before you choose what to defer. Here, the server sign-out did the damage, not the local cookie clear that everyone suspected.
- A matching reproduction does not identify the production trigger. Trace the initiating call in the affected build before naming it as the cause of an incident.