shashank created CAMEL-25095:
--------------------------------

             Summary: camel-jms - InOut: a send that fails after the request 
timeout or the reply has completed the exchange completes it a second time
                 Key: CAMEL-25095
                 URL: https://issues.apache.org/jira/browse/CAMEL-25095
             Project: Camel
          Issue Type: Bug
          Components: camel-jms
            Reporter: shashank


{{JmsProducer.processInOut}} registers the reply handler in the correlation map 
inside {{MessageCreator.createMessage}}, before {{MessageProducer.send()}} runs 
({{doSend}}). From that moment two other threads can complete the exchange:
* the request timeout: the timeout checker evicts the handler 
({{DefaultTimeoutMap.purge}}), and {{onTimeout}} runs 
{{ReplyManagerSupport.processReply}}, which sets an 
{{ExchangeTimedOutException}} and calls {{callback.done(false)}};
* the reply listener, if the request already reached the broker and was 
answered ({{handleReplyMessage}} removes the handler, then {{onReply}}).

CAMEL-24073 (4.22.0) added {{replyManager.cancelCorrelationId(...)}} to the 
catch block around the send, so that a send failure does not leave the handler 
behind for the timeout to fire later. But the catch block ignores whether the 
cancel still found the handler, and always rethrows. {{JmsProducer.process}} 
then sets the send exception on the exchange and calls {{callback.done(true)}}. 
If the timeout or the reply removed the handler while {{send()}} was still 
running, the exchange has already been completed and routed on, and the send 
failure completes it a second time.

When does a send run longer than the request timeout and then fail? A blocking 
send (persistent messages) to a broker that stops answering: the Artemis client 
gives up after {{callTimeout}}, 30 s by default, and the default 
{{requestTimeout}} of camel-jms is 20 s. Producer flow control or a network 
partition can do the same. (An ActiveMQ Classic synchronous send without 
{{sendTimeout}} blocks forever, so it does not hit this.) The second variant 
needs no timeout: the request reaches the broker and is answered, but 
{{send()}} then throws, for example because the acknowledgement of the send is 
lost. Then the reply and the send failure both complete the exchange. When the 
broker degrades, every in-flight request can be affected at once.

Effect on a route 
{{from("direct:start").doTry().to("jms:queue:req?requestTimeout=500").doCatch(Exception.class)...}}:
* the exchange completes on the timeout thread: the catch block runs with 
{{ExchangeTimedOutException}}, {{ExchangeCompleted}} is emitted and the 
on-completions of the exchange run as {{onComplete}};
* then the send failure completes it again on the caller thread: 
{{ExchangeFailed}} is emitted, the same on-completions run again as 
{{onFailure}}, and the caller gets the send exception although the catch block 
handled the failure;
* the inflight repository removes the exchange twice, so 
{{getInflightRepository().size()}} went to -1, -2, -3 after three such 
requests. A negative inflight count can make a graceful shutdown stop waiting 
for exchanges that are really inflight (not tested).

The test shows the double run for an on-completion added to the exchange. When 
the route starts at a consumer, the consumer's own on-completions (such as the 
commit or rollback of the consumed message) are registered the same way, so 
they would run twice as well, first as success and then as failure (not tested).

Affected: 4.22.0 and later in this form. Before 4.22.0 every send failure after 
the registration completed the exchange twice, whether or not the timeout came 
first (CAMEL-24073, not backported, so 4.18.x still has that broader form).

h3. Reproduction

An embedded Artemis broker (in-VM) and a {{JmsComponent}} whose 
{{ConnectionFactory}} is wrapped in a dynamic proxy, so that 
{{MessageProducer.send()}} to the request queue sleeps 1500 ms and then throws 
a {{JMSException}}. {{requestTimeout=500}}, 
{{requestTimeoutCheckerInterval=100}}.
* Producer level ({{endpoint.createAsyncProducer().process(exchange, 
callback)}}): the callback is called twice in 3 of 3 runs: {{done(false)}} 
after about 550 ms on a {{JmsReplyManagerOnTimeout}} thread with 
{{ExchangeTimedOutException}}, then {{done(true)}} after about 1520 ms on the 
caller thread with {{UncategorizedJmsException}}.
* Route level (the route above): 3 of 3 runs show {{onComplete}} and then 
{{onFailure}} for the same exchange, the caller sees 
{{UncategorizedJmsException}}, and the inflight count is -1, -2, -3.
* Variant: the proxy sends the message first, then sleeps 700 ms and throws. 
The reply completes the exchange ({{done(false)}} after about 10 ms), then the 
send failure completes it again ({{done(true)}} after about 715 ms), 3 of 3 
runs.
* Controls: a send that fails at once (the CAMEL-24073 case) and a slow send 
that succeeds both complete the exchange exactly once.

A TLA+ model of the producer thread, the timeout checker, the reply listener 
and the broker checks that the callback is called at most once. It is violated 
on the current code by the orders Register, SendFail, Expire, Evict, 
OnSendFailure, TimeoutRun and Register, Reach, Answer, SendFail, ReplyRecv, 
OnSendFailure. The model reproduces CAMEL-24073 on the code before that fix, 
and the fixed model holds the property and also completes every exchange.

h3. Proposed fix

The party that removes the handler from the correlation map owns the completion 
of the exchange. The removal is already atomic in all three paths (timeout 
eviction, reply, cancel), so the cancel only has to report it:
* {{ReplyManager.cancelCorrelationId}} returns {{true}} if it removed a pending 
handler, {{false}} otherwise.
* In the catch block of {{processInOut}}: if a handler was registered and 
{{cancelCorrelationId}} returns {{false}}, the timeout or the reply completes 
the exchange ({{onTimeout}} may still be queued on the timeout thread pool). 
Log the send failure at WARN and return {{false}} without touching the 
exchange. Otherwise rethrow as today, which keeps the CAMEL-24073 behaviour.

When the reply manager stops, its correlation map evicts every pending handler 
and completes those exchanges with a {{RejectedExecutionException}}, so a 
{{false}} from the cancel means that another thread completes the exchange. 
(The one exception already exists today: an {{onTimeout}} task that the timeout 
thread pool rejects because it is shutting down.)

{{cancelCorrelationId}} was added to the public {{ReplyManager}} interface in 
4.22.0, so the return type change needs one line in the 4.23 upgrade guide.

Tests: deterministic cases in {{JmsInOutSendFailureCallbackTest}} (the 
CAMEL-24073 test), with a latch in the proxied send instead of sleeps: the 
timeout completes the exchange during a send that then fails (with and without 
a {{doCatch}}), and the reply completes it during a send that then fails. Each 
asserts one {{onComplete}} or one {{onFailure}}, the outcome of the timeout or 
the reply, and an inflight count of 0.

Duplicate check (2026-09-28): JIRA component camel-jms since June 2025, 
including CAMEL-24073, CAMEL-24074, CAMEL-24124, CAMEL-24401 and the review 
umbrella CAMEL-24078 with its findings list: none covers a timeout or a reply 
during the send. GitHub PRs for {{JmsProducer}} and {{cancelCorrelationId}}: 
apache/camel#24730 (CAMEL-24073) only.

_Filed with Claude Code on behalf of allthingssecurity._




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

Reply via email to