Repository navigation
Stop monitors racing their own subscription when the channel closes - #47
Merged
Merged
Conversation
CaGetConcurrencyTest failed in CI on main (75 monitors never reported connected). A packet capture of the test against the IOC shows why. CAJ queues a monitor's EVENT_ADD until a flush but sends EVENT_CANCEL at once, so a cancel that closely follows the add reaches the IOC first. The IOC rejects it (bad monitor subscription identifier) and drops the connection, with every channel on it. ChannelMonitor made that likely. It never removed its connection listener or cleared its subscription, so on a channel a /caget kept open, a closed monitor still subscribed when its connection callback ran late. When the last /caget then destroyed the channel, CAJ cancelled that fresh subscription. Subscriptions also piled up on the shared channel, one per monitor. When the drop landed while a monitor was closing, destroyChannel threw IllegalStateException, which close() didn't catch. It escaped removePv's compute, leaving the dead monitor mapped, and every later client of that PV was told it was disconnected. ChannelMonitor.close() now: - stops the monitor subscribing after close, and removes its connection listener from the shared channel; - clears its own subscription, after the first update shows the IOC has it (waiting at most 3 s); - reports IllegalStateException as IOException too, so removePv always unmaps the monitor. MonitorEndpoint also logs the message of an IllegalStateException, which its log call dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VbHof7Yu45SnHgQB9V1CwD
slominskir
approved these changes
Oct 4, 2026
This was referenced Oct 4, 2026
Merged
Merged
slominskir
pushed a commit
that referenced
this pull request
Oct 4, 2026
AGENTS.md tells coding agents how to check their work here: the unit and integration test commands, the test IOC's PVs and what tests rely on, and the rules for code that uses CAJ that earlier bugs taught (#29, #42, #47 and others). It also covers the conventions for commits, pull requests and releases. Based on the coding-agents starter file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the intermittent
CaGetConcurrencyTestfailure in CI onmain(run 53: "Monitors that never reported connected expected:<0> but was:<75>").A cloud Claude session diagnosed the failure and wrote this commit (its session is linked in the commit trailer). I reviewed it, checked its claims, and applied it unchanged except for setting the author to the repository's bot.
Cause
Since #45, a
/cagetand a monitor of the same PV share one CAJ channel.EVENT_ADDuntil a flush, but sendsEVENT_CANCELat once (EventCancelRequest.getPriority()isSEND_IMMEDIATELY_PRIORITY; I checked the bytecode of jca 2.4.10). A cancel that closely follows its add can therefore reach the IOC first. The IOC rejects it and drops the whole circuit, with every channel on that IOC.ChannelMonitormade that likely: it never removed its connection listener or cleared its subscription onclose(). On a channel a/cagetkept open, a closed monitor could still subscribe when its connection callback ran late, and subscriptions piled up. When the last/cagetthen destroyed the channel, CAJ cancelled every subscription still on it, including that fresh one.destroyChannelthrewIllegalStateException.close()didn't catch it, so it escapedremovePv'scomputeIfPresentand left the dead monitor mapped. Every later client of the PV was then told it was disconnected.The CI run's server log matches: the IOC logged
CAS: forcing disconnect, then epics2web loggedtransport closedandUnable to destroy channel channel2, followed by 76Unable to handle client messagelines.Changes
ChannelMonitor.close()now:IllegalStateExceptionasIOException, soremovePvalways unmaps the monitor.MonitorEndpointnow logs the message of anIllegalStateException, which its log call used to drop.Waiting inside
computeIfPresent: the first update arrives on a CAJ thread, and nothing on that path takesmonitorMaplocks. Connection events go throughcallbackExecutor, and the closing monitor has no listeners left. The wait happens only while the channel is connected with an unconfirmed subscription, which is normally milliseconds.Checks
Run locally with Tomcat CPU-limited (
docker update --cpus) to make the race likelier:CaGetConcurrencyTestforcing disconnectmain(61761e6)mainmainmain, and never in 30 with this PR. The 75-monitor failure itself didn't reproduce locally, so this PR's CI runs are the real confirmation.CaGetConcurrencyTest3.0 s)../gradlew spotlessCheck testpasses.User destroyed channel(7 in the patched runs). The handoff found these come from CAJ destroying a shared channel while a get is pending, before and after this change. They don't drop the circuit.🤖 Generated with Claude Code