shashank created CAMEL-25036:
--------------------------------

             Summary: camel-support - EventDrivenPollingConsumer.receive() 
holds the service lifecycle lock while it waits, so stopping a 
ConsumerTemplate, a pollEnrich route or the CamelContext hangs forever 
(regression in 4.8)
                 Key: CAMEL-25036
                 URL: https://issues.apache.org/jira/browse/CAMEL-25036
             Project: Camel
          Issue Type: Bug
          Components: camel-core
            Reporter: shashank


{{EventDrivenPollingConsumer.receive()}} and {{receive(timeout)}} 
({{core/camel-support/.../EventDrivenPollingConsumer.java:128-153}}, 
{{:156-178}}) wrap {{beforePoll}} / {{queue.take()}} (or 
{{queue.poll(timeout)}}) / {{afterPoll}} in {{lock.lock()}} ... 
{{lock.unlock()}}. That {{lock}} is not a field of the class: it is the 
inherited {{BaseService.lock}} ({{core/camel-api/.../BaseService.java:58}}), 
the lock that {{BaseService.start()}}, {{stop()}}, {{suspend()}}, {{resume()}} 
and {{shutdown()}} take ({{:114}}, {{:159}}, {{:195}}, {{:227}}, ...).

So while a thread waits in {{receive()}} for a message, nobody can stop the 
polling consumer: {{stop()}} blocks on the lock until a message arrives. On an 
idle endpoint that is forever.

This came in with commit ee18bef57f (part of the CAMEL-20199 virtual-threads 
work, released in 4.8.0), which replaced {{synchronized (this)}} in {{receive}} 
with {{lock.lock()}}. Before that the receive monitor ({{this}}) and the 
lifecycle monitor ({{BaseService.lock}}, then a plain {{Object}}) were 
different, so {{stop()}} did not wait for {{receive()}}. Affected: 4.8.x, 
4.14.x, 4.18.x, main.

Every endpoint that does not override {{createPollingConsumer()}} uses 
{{EventDrivenPollingConsumer}}, as do consumers that implement 
{{PollingConsumerPollingStrategy}}. {{GenericFilePollingConsumer}} (file, ftp, 
sftp, smb) extends it but only takes the lock once a file is already queued, so 
it does not hang this way. Seda has its own {{SedaPollingConsumer}} and is not 
affected.

Consequences:
# {{pollEnrich}} with the default timeout (-1, {{receive()}}) on an endpoint 
that has no message: stopping the route or the CamelContext never returns. The 
graceful shutdown times out, and the forced shutdown then blocks in 
{{PollEnricher.doStop}} -> {{DefaultConsumerCache.doStop}} -> 
{{ServicePool.stop}} -> {{EventDrivenPollingConsumer.stop()}}.
# {{ConsumerTemplate.receive(uri)}} in one thread: {{consumerTemplate.stop()}} 
and {{camelContext.stop()}} hang.
# The same lock also makes {{receiveNoWait()}} and {{receive(uri, timeout)}} of 
other threads wait until the blocked {{receive()}} gets a message, because they 
share the polling consumer through the consumer cache. {{receiveNoWait()}} does 
not return at once, and {{receive(timeout)}} ignores its timeout. This part is 
older, since {{synchronized (this)}} serialized them too, and it has the same 
fix.

*Reproduction* (4.23.0-SNAPSHOT, {{direct:idle}} = a DefaultEndpoint, so 
EventDrivenPollingConsumer):
{noformat}
template: thread A: consumerTemplate.receive("direct:idle")   -> waiting in 
EventDrivenPollingConsumer.receive:141
          consumerTemplate.stop()   STILL BLOCKED after 4000 ms   at 
BaseService.stop:159 <- ServicePool$SinglePool.doStop:283 <- 
DefaultConsumerCache.doStop:271
          camelContext.stop()       STILL BLOCKED after 4000 ms
route:    from("direct:start").pollEnrich("direct:idle"), shutdown timeout 2 s, 
one exchange waiting in pollEnrich
          camelContext.stop()       STILL BLOCKED after 10000 ms  at 
BaseService.stop:159 <- ... <- DefaultConsumerCache.doStop:271 <- 
PollEnricher.doStop:667
nowait:   thread A blocked in receive("direct:idle"); on the same 
ConsumerTemplate:
          receiveNoWait("direct:idle")  STILL BLOCKED after 4000 ms (returned 
null after 8022 ms, once a message was sent for A)
          receive("direct:idle", 500)   STILL BLOCKED after 4000 ms
{noformat}
The same harness with the 4.7.0 {{EventDrivenPollingConsumer}} (control): 
{{consumerTemplate.stop()}} returns after 2 ms, {{camelContext.stop()}} after 8 
ms, and the route case stops after the 2 s shutdown timeout. With the fix below 
all three return promptly: stop in 1 ms, {{receiveNoWait}} null in 0 ms, 
{{receive(500)}} null in 505 ms, and the route context stops after 2017 ms.

A TLA+ model with receive(), receiveNoWait(), stop() and the delegate consumer. 
For the current code, {{StopTerminates}} is violated ({{pc_shared_stop_live}}: 
RLoop -> RAcq, i.e. waiting in take, then stop waits forever) and so is 
{{NoWaitTerminates}} ({{pc_shared_nowait_live}}). The pre-4.8 variant satisfies 
{{StopTerminates}}. The fix variant satisfies all properties ({{pc_fix_all}}, 
{{pc_fix_interrupt}}).

*Proposed fix:*
* Use a dedicated lock instead of the inherited lifecycle lock, held only while 
{{beforePoll}}/{{afterPoll}} run and a count of the receive calls in progress 
is updated, never while waiting for a message. {{afterPoll}} is only run by the 
last receive call in progress, which keeps the CAMEL-10215 guarantee (one 
caller's {{afterPoll}} must not strand another caller that is still polling). 
This restores the pre-4.8 behaviour for stop.
* Make {{receive()}} return when the consumer stops: wait in slices (for 
example {{queue.poll(1s)}} in the {{while (isRunAllowed())}} loop), so that a 
stopped consumer returns null, which is what the existing "Consumer is not 
running, so returning null" path intends.
* {{receive(timeout)}} keeps its single timed poll, and no longer waits behind 
other callers.

Suggested test: a ConsumerTemplate receive() on an idle endpoint in a thread, 
then {{context.stop()}} must return; a pollEnrich with the default timeout on 
an idle endpoint, then stopping the context must return; and receiveNoWait() 
from a second thread must return at once.

A companion ticket, filed together with this one, covers an interrupted 
{{receive()}} that spins forever.

_Filed with Claude Code on behalf of allthingssecurity._




--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to