Skip to content

fix(client): deliver exactly one terminal SSE callback (#1170) - #1173

Open
chopmob-cloud wants to merge 1 commit into
a2aproject:mainfrom
chopmob-cloud:fix/sse-listener-single-terminal-callback-1170
Open

chopmob-cloud wants to merge 1 commit into
a2aproject:mainfrom
chopmob-cloud:fix/sse-listener-single-terminal-callback-1170

Conversation

@chopmob-cloud

Copy link
Copy Markdown

Summary

Fixes #1170. The JSON-RPC SSE listeners guarded only onComplete() with a volatile completed flag, so onError, a parse error, and post-cancellation signals could each still reach the error/completion consumer, delivering more than one terminal callback (including a null completion after an error or cancellation).

Fix

Introduce one shared atomic transition. AbstractSSEEventListener now owns an AtomicBoolean and a signalTerminal(Throwable) helper: the first caller to win compareAndSet(false, true) delivers exactly one outcome (a non-null failure or a null normal completion) and every later signal is dropped. onError, onComplete and the JSON-RPC error/parse path all route through it. The 0.3 compatibility JSON-RPC listener, which does not share the base class, mirrors the same AtomicBoolean pattern. REST is left unchanged (both native and 0.3 use a no-op completion callback, an API decision as noted in the issue).

Tests

Added coverage on both listeners for complete-then-error, error-then-complete, repeated completion, a 32-thread concurrent race, and a final event followed by completion, each asserting exactly one terminal callback. mvn -pl client/transport/jsonrpc,compat-0.3/client/transport/jsonrpc -am test passes (SSEEventListenerTest 13, SSEEventListener_v0_3_Test 12).

Note

Scoped to the terminal-callback contract in the issue. A separate, related question: the listeners do not uniformly treat a malformed frame as terminal (the native parseResponseEvent can raise an unchecked Gson JsonSyntaxException out of onMessage; the 0.3 listener logs and continues on a malformed frame). Whether a malformed frame should end the stream or be skipped is a semantics decision. Happy to follow up once you confirm the intended behaviour.

The JSON-RPC SSE listeners guarded only onComplete() with a volatile
completed flag, so onError, a parse failure, and post-cancellation
signals could each still reach the error/completion consumer, delivering
more than one terminal callback (including a null completion after an
error or cancellation).

Introduce one shared atomic transition. AbstractSSEEventListener now
owns an AtomicBoolean and a signalTerminal(Throwable) helper that lets
the first caller win via compareAndSet and delivers exactly one outcome
(a non-null failure or a null normal completion); every later signal is
dropped. onError, onComplete and the parse-error path all route through
it. The 0.3 compatibility JSON-RPC listener, which does not share the
base class, mirrors the same AtomicBoolean pattern. REST is left as is
(both native and 0.3 use a no-op completion callback, an API decision).

Tests cover complete-then-error, error-then-complete, repeated
completion, a 32-thread concurrent race, and a final event followed by
completion, asserting exactly one terminal callback in each.

Signed-off-by: AlgoVoi <chopmob@gmail.com>
@kabir

kabir commented Sep 23, 2026

Copy link
Copy Markdown
Collaborator

Thanks for taking this on and adding coverage. One case still looks unhandled: in AbstractSSEEventListener.handleEvent, the final-event path auto-closes the stream via future.cancel(true), but does not set terminalSignaled. If cancellation triggers onError, signalTerminal can still deliver that cancellation as a failure, and a later onComplete is dropped. Could you route final-event close through the terminal transition (or suppress cancellation errors after a final event) and add a final-event-then-onError regression test?

This branch has not been deployed

No deployments
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.

Fix duplicate terminal callbacks in SSE listeners

2 participants