Skip to content

Do not report an existing session as missing in getOrCreateSessionHolder - #119

Merged
Ololoshechkin merged 1 commit into
agentclientprotocol:masterfrom
nikita-ashihmin:nikita.ashihmin/fix-session-update-race
Aug 11, 2026
Merged

Do not report an existing session as missing in getOrCreateSessionHolder#119
Ololoshechkin merged 1 commit into
agentclientprotocol:masterfrom
nikita-ashihmin:nikita.ashihmin/fix-session-update-race

Conversation

@nikita-ashihmin

Copy link
Copy Markdown
Contributor

Problem

Client.getOrCreateSessionHolder reads the session map once, before entering the _sessions.update CAS loop, and the loop's initializingSessionsCount == 0 branch never consults currentStorage again:

val sessionsStorage = _sessions.value
val holder = sessionsStorage.sessions[sessionId]
if (holder != null) return holder            // only existence check
_sessions.update { currentStorage ->
    if (currentStorage.initializingSessionsCount > 0) { /* looks the session up */ }
    else {
        clientSessionHolder = null           // ignores currentStorage.sessions[sessionId]
        currentStorage
    }
}
return clientSessionHolder ?: acpFail("Session $sessionId not found")

_sessions can change between that read and the CAS, and again between two attempts of the CAS loop. When a session/update is handled on the protocol read loop while a concurrent newSession/loadSession is both registering its holder and decrementing the counter back to zero, the notification lands in the else branch and fails with

SEVERE com.agentclientprotocol.protocol.Protocol - Error handling notification MethodName(name=session/update)
com.agentclientprotocol.protocol.AcpExpectedError: Session 714f8c4c-7914-4e11-9d3e-f2d85dfd5a41 not found
	at com.agentclientprotocol.protocol.ProtocolKt.acpFail(Protocol.kt:50)
	at com.agentclientprotocol.client.Client.getOrCreateSessionHolder(Client.kt:119)
	at com.agentclientprotocol.client.Client$9.invokeSuspend(Client.kt:211)

even though the session exists. The update is dropped.

How it shows up

Agents that emit an update as soon as the session exists hit this in the field. With claude-agent-acp the session/new response and the first available_commands_update arrive in the same read:

00:20:33,277 OUT session/new
00:20:33,678 IN  id=2 result {"sessionId":"714f8c4c-..."}
00:20:33,678 IN  session/update available_commands_update
00:20:33,682 SEVERE Session 714f8c4c-... not found
00:20:34,182 OUT session/close                            <- +500 ms, the caller's own timeout

A sibling probe against the same agent, whose notification happened to arrive 2 ms after the response, was unaffected. The caller here was waiting for available_commands_update, so the dropped notification cost it a full grace period and it ended up with an empty command list.

Fix

Look the session up inside the atomic block, so every branch — including a retry after a failed CAS — sees the map as it is at that moment. The read before the loop stays as a fast path for the already-registered case.

Test

ClientSessionUpdateDeliveryTest drives a fake agent that answers session/new and, without yielding, sends one session/update for the session it just created, with newSession running off the protocol read loop's thread.

The interleaving is only reachable under real parallelism, so this is a probabilistic stress test rather than a deterministic one: correct code can never fail it, while on master it fails in roughly one run out of three on a warm 10-core laptop, always with AcpExpectedError: Session session-<n> not found and one lost update. Increasing the attempt count does not help much — the hits cluster rather than distribute uniformly — so it is sized for ~1.7 s.

:acp:jvmTest, :acp-model:jvmTest and :acp-ktor-test:jvmTest pass.

`getOrCreateSessionHolder` looked the session up once, before entering the
`_sessions.update` CAS loop, and the loop's `initializingSessionsCount == 0`
branch never consulted `currentStorage` again. A `session/update` handled on
the protocol read loop while a concurrent `newSession`/`loadSession` was both
registering its holder and decrementing the counter back to zero therefore
took that branch, and the notification failed with

    AcpExpectedError: Session <id> not found

even though the session existed. The update was dropped, and clients that wait
for one - a session-setup probe waiting for `available_commands_update`, say -
stalled until their own timeout.

Agents that emit an update immediately after the `session/new` response hit
this in the field: with claude-agent-acp the response and the notification
arrive in the same read, and the notification loses roughly one session in
tens of thousands.

Look the session up inside the atomic block, so every branch - including the
retry after a failed CAS - sees the map as it is at that moment. The read
before the loop stays as a fast path for the common case.
@Ololoshechkin
Ololoshechkin merged commit b7d0036 into agentclientprotocol:master Aug 11, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants