shashank created CAMEL-25609:
--------------------------------
Summary: camel-browse - browseLimit (advanced, off by default)
does not release old exchanges (the limit is a sub list view), and with
concurrent producers a stale view makes the endpoint fail with
ConcurrentModificationException until restart
Key: CAMEL-25609
URL: https://issues.apache.org/jira/browse/CAMEL-25609
Project: Camel
Issue Type: Bug
Components: camel-core
Reporter: shashank
Assignee: shashank
Scope: {{browseLimit}} on the browse endpoint is an advanced option that is off
by default (0 = keep everything), on a component used for testing and
debugging. Nothing in Camel sets it: the only caller of {{setBrowseLimit}} on a
browse endpoint is {{BrowseComponent}}, which copies its own option of the same
name (default 0). Only users who set the option are affected.
{{BrowseEndpoint.onExchange}} (lines 141-151 at main a7169d7c6ab6) makes room
for a new exchange like this:
{code:java}
if (browseLimit > 0) {
// make room if end of capacity
int max = exchanges.size();
if (max >= browseLimit) {
int space = Math.abs(max - (browseLimit - 1));
exchanges = exchanges.subList(space, max);
}
}
exchanges.add(exchange);
{code}
{{exchanges}} is a {{CopyOnWriteArrayList}} ({{createExchangeList}}).
{{subList}} returns a view of that list, and a sub list of a sub list is again
a view of the same backing list; {{add}} on the view appends to the backing
list. Nothing is ever removed from the backing list.
Two effects:
# *Exchanges are not released (reproduced).* The option says "Maximum number of
messages to keep in memory available for browsing". With
{{browse:foo?browseLimit=10}} a browser sees the last 10 exchanges, but the
endpoint holds every exchange that was ever sent to it (1000 after 1000
messages), with bodies and headers, until the endpoint is stopped. The limit
has no effect on memory, and because the list is copy-on-write every new
exchange copies the whole, growing backing array.
# *A stale view fails (reproduced).* A sub list of a {{CopyOnWriteArrayList}}
throws {{ConcurrentModificationException}} when the backing list was changed
through another view.
#* A list that {{getExchanges()}} returned can no longer be read after one more
exchange arrived ({{size()}}, {{get}}, iteration all fail). Readers such as
{{ManagedBrowsableEndpoint}} ({{getExchanges().size()}}, then {{get(index)}})
can hit this when a message arrives in between.
#* Two threads sending to the same endpoint: thread A creates its view, thread
B creates its own view and adds, then A stores its view in the field, which is
now outdated. A's {{add}} fails, the outdated view stays in the field, and from
then on every exchange sent to the endpoint ({{exchanges.size()}} in
{{onExchange}}) and every read of it fails with
{{ConcurrentModificationException}} until the endpoint is restarted. Reproduced
with a test that holds thread A exactly between creating and storing the view
(not a load test). This failure is loud: the producer throws and the route's
error handling sees it.
A smaller point in the same lines: the view (with {{browseLimit - 1}} entries)
is stored before the add, so a reader can see one exchange fewer than the limit
while an exchange arrives, and an empty endpoint with {{browseLimit=1}}
({{BrowsableEndpoint.getBrowseStatus}} reports size 0). Reproduced.
Without {{browseLimit}} the endpoint keeps everything by design and none of
this applies.
h3. Reproduction
{{BrowseLimitMemoryTest}} in camel-core, 5 tests; all 5 fail on main:
* {{testOldExchangesAreReleased}}: a {{BrowseEndpoint}} with {{browseLimit=10}}
whose {{createExchangeList}} hands the created list to the test; 1000 messages.
The browser sees the last ten on main and with the fix; the list that holds the
exchanges must have 10 entries. On main: {{expected: <10> but was: <1000>}}.
* {{testBrowsedListStaysUsableWhenMoreExchangesArrive}}:
{{browse:live?browseLimit=2}}, three messages, {{getExchanges()}}, one more
message, then {{size()}} of the list from before. On main:
{{java.util.ConcurrentModificationException}}.
* {{testSecondProducerWhileFirstMakesRoom}}: {{browseLimit=2}}, two exchanges,
then producers A and B with the interleaving above (a delegating list holds A
when it asks for the sub list, until B has completed), then a third exchange C
and a read. On main: {{failures: [producer A:
java.util.ConcurrentModificationException, producer C, after A and B:
java.util.ConcurrentModificationException, browsing, after A, B and C:
java.util.ConcurrentModificationException]}}.
* {{testManyProducerThreads}}: 4 threads, 250 exchanges each,
{{browseLimit=10}}. Depends on scheduling; on main all 4 threads failed with
the exception in each of the 3 attempts of the test run.
* {{testReaderDoesNotSeeTheEndpointEmptyWithLimitOne}}: {{browseLimit=1}}, the
browse status is read while the producer is inside the list. On main: {{sizes
seen by a reader while exchanges arrive: [1, 0, 1, 0]}}.
h3. Proposed fix
Add the exchange, then remove the oldest ones from the list itself:
{{exchanges.subList(0, excess).clear()}} with {{excess = size - browseLimit}}
(one array copy for a {{CopyOnWriteArrayList}}). Both steps are done under a
lock, so that concurrent producers keep exactly {{browseLimit}} exchanges; the
lock is only taken when {{browseLimit}} is set. The filter is not touched.
What a browser sees does not change: the last {{browseLimit}} exchanges in
arrival order ({{BrowseLimitTest}} is unchanged and passes). Because the add
comes first, a reader never sees fewer than the limit once it was reached;
while an exchange arrives it can see {{browseLimit + 1}} for a moment.
{{getExchanges()}} now always returns the endpoint's list, as it already does
without a limit and before the limit is reached.
No upgrade note is needed: no option, default or result changes; a route that
worked keeps working and a browser sees the same exchanges. What goes away is
the retained memory and the exception.
Tests with the fix: {{BrowseLimitMemoryTest}} 5 of 5 pass; the full camel-core
suite: 8171 run, 0 failures, 0 errors, 45 skipped; the browse tests of
camel-management: 19 run, 0 failures.
How it was found: a boundary sweep with a small Lean 4 model of the window
(main: backing list plus offset of the view; fix: a list). The model proves
that main keeps every exchange in the backing list for any limit (so 1000 for
the example above), that the fixed list never has more than {{browseLimit}}
entries, and that the fix shows a browser exactly what main showed. The model
does not cover the {{ConcurrentModificationException}}, concurrent producers or
the moment between add and remove; those are in the Java tests.
Affected: every release since 4.9.0, where CAMEL-21183 added the option (the
4.9.0 tag has the line, 4.8.0 does not). main (a7169d7c6ab6; tested);
camel-4.23.x, camel-4.22.x, camel-4.18.x and camel-4.14.x have the same line
147 (read, not run).
Duplicate check (2026-10-13): JIRA text "browseLimit" (2: CAMEL-24076 and
CAMEL-24078, the browseLimit option of camel-jms / camel-amqp, other code),
"BrowseEndpoint subList" (none), "browse endpoint memory leak" (5, none about
the browse component); GitHub pull requests "browseLimit": #24735 (camel-jms),
no other.
_Filed with Claude Code on behalf of allthingssecurity._
--
This message was sent by Atlassian Jira
(v8.20.10#820010)