The smart cast that vanished in a lambda
A Kotlin 2.0 service has private var activeSession: Session? = null. A method does: if (activeSession != null && activeSession.isExpired()) { activeSession.refresh() }. After a refactor wrapped the refresh in runCatching { activeSession.refresh() }, compilation fails: Smart cast to 'Session' is impossible, because 'activeSession' is a mutable property that could have been changed by this time. A teammate changes the property to val and the error disappears, but the session is replaced by a scheduled job every 15 minutes, so val is not viable. Another teammate suggests activeSession!!, which passes review until a race during rotation causes an NPE in 0.02% of sessions. The code also calls activeSession?.let { it.refresh() } elsewhere and the team believes this is atomic. The module targets JVM 17, uses @Volatile on the property, and has a 40 ms p99 refresh path.
1. Why does the smart cast fail inside `runCatching`, and why does `@Volatile` not fix it? Does `activeSession?.let { it.refresh() }` make it atomic?
Read the worked answer
Smart casts require a stable value. A local val is stable; a mutable var property is not, because any thread-or any lambda invoked later-can change it between the check and the use. runCatching { activeSession.refresh() } captures the property in a lambda, so the compiler cannot prove the non-null check still holds when the lambda runs. @Volatile guarantees visibility of writes, not atomicity: it prevents stale reads but does not stop another thread from setting the property to null between the check and refresh(). The check-then-act sequence is still a race. activeSession?.let { it.refresh() } is different: the safe call reads the property once, and it is a stable non-null local inside the lambda, so it cannot NPE from a concurrent null assignment. That makes null handling atomic for that one read, but it is not a lock: the session can be replaced or closed after the read, so you may refresh a stale session. activeSession!! is strictly worse: it converts a recoverable null into an NPE and passes review only because the race is rare. The compiler's refusal to smart-cast is correct.
2. Propose a correct pattern with a local `val`. Is the local `val` enough under concurrency? What about the scheduled replacement?
Read the worked answer
Read once into a local: val session = activeSession ?: return or val session = activeSession ?: throw IllegalStateException(...). Then call session.refresh(); the local cannot be changed by another thread. This fixes the compile error and the null race for that operation, but it does not make the session lifecycle safe. If a scheduled job can replace or close the session while refresh() is running, you need a coordination primitive: a Mutex/synchronized block around replace-and-refresh, an AtomicReference<Session?> with compareAndSet and a retry loop, or reference counting so close() waits for in-flight refreshes. Do not hold a lock across a 40 ms network call if you can avoid it; instead mark the session as closing, let refresh finish, then close. @Volatile on the property plus the local val gives a coherent snapshot and visibility, but the local val alone is not a mutual-exclusion mechanism. If refresh is idempotent and the session object is immutable, a stale refresh may be harmless; decide that explicitly. Document the chosen policy: last-writer-wins, fail-fast, or retry.
3. How would you verify the concurrency fix deterministically? What would you assert about `@Volatile` and the smart cast?
Read the worked answer
Test the state machine deterministically rather than hoping to hit the race. Extract a SessionManager with a fake Session whose refresh() blocks on a CountDownLatch. Thread A enters refresh and waits; thread B replaces the session; release the latch; assert no NPE, the old session was closed exactly once, and the new session is active. Use CyclicBarrier to force the interleaving, and a timeout on await so a deadlock fails fast. For coroutine code, use runTest with a single-threaded dispatcher and a Mutex; advanceUntilIdle makes the interleaving reproducible. You cannot unit-test @Volatile directly; verify by code review and by compiling with the property private and all access through the manager. A smart-cast regression is caught by the compiler: keep the runCatching lambda in a test source set or a compile-only fixture so the build fails if someone reintroduces the pattern. Also add a test for the ?: return path with a null session, and one for ?.let to document that it reads once. Do not assert on timing or use Thread.sleep; that would recreate the flakiness this fix is meant to remove.