shashank created CAMEL-25582:
--------------------------------

             Summary: camel-vertx-websocket - a consumeAsClient consumer starts 
overlapping reconnect attempts: after a slow handshake it holds up to 20 
WebSockets and routes every message once per WebSocket; it also keeps 
reconnecting after its route is stopped
                 Key: CAMEL-25582
                 URL: https://issues.apache.org/jira/browse/CAMEL-25582
             Project: Camel
          Issue Type: Bug
          Components: camel-vertx-websocket
            Reporter: shashank
            Assignee: shashank


When the WebSocket of a consumer with {{consumeAsClient=true}} is closed, its 
close handler starts a periodic timer, and every run of the timer starts a 
connect attempt on a worker thread 
({{VertxWebsocketClientConsumer.configureWebSocketHandlers}}, lines 52-85 at 
main c3829069cb72):

{code:java}
vertx.setPeriodic(configuration.getReconnectInitialDelay(), 
configuration.getReconnectInterval(), timerId -> {
    vertx.executeBlocking(() -> {
        getEndpoint().getWebSocket(this::configureWebSocketHandlers);
        vertx.cancelTimer(timerId);
        return null;
    }, false)
{code}

{{VertxWebsocketEndpoint.getWebSocket}} (lines 164-203) connects and waits for 
the handshake for up to the connect timeout of the client options (60 seconds 
by default, line 200). The timer runs again after {{reconnectInterval}} (1 
second by default), and nothing checks whether the previous attempt has ended: 
{{executeBlocking(..., false)}} runs the attempts in parallel, up to the size 
of the Vert.x worker pool (20 by default).

*1. Duplicate WebSockets and duplicate delivery.* When the server answers the 
handshake later than {{reconnectInterval}} (a TCP connect with a retransmitted 
SYN, a proxy or ingress that waits for its backend, a server that is still 
starting), all the attempts in flight succeed. Each of them registers the 
handlers of the consumer on its own WebSocket (the setup callback, line 188). 
The endpoint keeps the last one in its field; the others stay open until the 
endpoint stops. From then on every message the server sends to its clients is 
routed once per WebSocket. When these WebSockets are closed later, each close 
handler starts a timer of its own, so the next reconnect starts with that many 
timers.

*2. Reconnecting does not stop with the consumer.* The consumer has no 
{{doStop}}. The timer is only cancelled by a successful attempt (line 62) or by 
{{maxReconnectAttempts}} (0 = unlimited by default). A route that is stopped 
while the server is away goes on connecting every interval, and the WebSockets 
of the attempts that succeed after the stop stay open (messages on them were 
not tested).

*3.* A connect that completes after the caller stopped waiting (the 
{{future.get}} timed out, line 200) still registers the handlers (line 188) and 
its WebSocket is not kept or closed by anyone.

h3. Reproduction

New {{VertxWebsocketConsumerAsClientSlowReconnectTest}} (4 tests). The server 
is a plain Vert.x HTTP server in the test that counts handshakes and WebSockets 
and can accept a handshake at once, accept it after 1 second, hold it until the 
test answers it, or reject it. The route is 
{{from("vertx-websocket:localhost:<port>/feed?consumeAsClient=true&reconnectInterval=50")}}.
 On main all 4 fail:
* {{reconnectSlowerThanTheIntervalOpensOneWebSocket}}: the server closes the 
WebSocket and answers the next handshakes after 1 second; then it sends one 
message on every WebSocket it has. {{handshakes sent for one reconnect ==> 
expected: <1> but was: <20>}}, {{WebSockets the consumer has open after one 
reconnect ==> expected: <1> but was: <20>}}, {{mock://result Received message 
count. Expected: <1> but was: <20>}}.
* {{reconnectThatCompletesAfterStopIsClosed}}: the route is stopped while a 
reconnect is in flight. {{WebSockets pending or open after the consumer was 
stopped ==> expected: <0> but was: <20>}}.
* {{stoppedConsumerDoesNotReconnect}}: the server rejects the handshake, the 
route is stopped. {{Condition with alias 'no reconnect attempt after the 
consumer was stopped' didn't complete within 10 seconds}} (the handshakes go 
on).
* {{restartWhileReconnectIsPendingOpensOneWebSocket}}: the route is stopped and 
started again while the server holds the handshake of a reconnect, then the 
server answers. {{handshakes sent by the reconnect and the restart ==> 
expected: <2> but was: <6>}}, {{WebSockets the consumer has open after the 
restart ==> expected: <1> but was: <6>}} (6 in this run; the number depends on 
how many timer runs fit in before the stop).

h3. Proposed fix

In {{VertxWebsocketClientConsumer}}:
* one reconnect timer per consumer: a close event while the timer runs does not 
start another one (a WebSocket that is found closed when its attempt returns 
starts the timer again);
* a run of the timer is skipped while an attempt is in flight;
* {{doStop}} cancels the timer, and a run of the timer of a stopped consumer 
cancels it too;
* start and reconnect connect under one lock of the endpoint (a route that is 
stopped and started again gets a new consumer, while an attempt of the previous 
one can still wait for its handshake);
* an attempt that returns after its consumer was stopped closes the WebSocket 
it opened, before it releases that lock, so that a start which waits for it 
connects again.
In {{VertxWebsocketEndpoint.getWebSocket}}: a connect that completes after the 
caller stopped waiting closes its WebSocket instead of registering the 
handlers; the same when the setup callback throws.

Behaviour change, with an entry in the 4.24 upgrade guide: one attempt at a 
time, so with a server that does not answer, attempts follow each other (each 
up to the connect timeout) instead of running in parallel, and 
{{maxReconnectAttempts}} counts them one after the other; a stopped consumer no 
longer reconnects.

Not changed (follow-ups): stopping the route does not close a WebSocket that is 
open (as on main, it is closed when the endpoint stops; it belongs to the 
endpoint and a producer on the same endpoint uses it too); the producer side of 
{{getWebSocket}} is not serialised, so concurrent exchanges that find the 
WebSocket closed can each open one.

camel-vertx-websocket with the fix: 107 tests, 0 failures.

How it was found: a small TLA+ model of the reconnect on main (close events, 
timer runs, attempts in flight, stop), with the assumption that an attempt can 
still be in flight when the timer runs again. The model checker finds traces of 
the model in which the consumer is registered on two open WebSockets (close, 
two timer runs, both attempts succeed) and in which the timer runs after the 
stop. This reproduces the defect on a model of main; it is not a proof of the 
fix. The defect was then confirmed with the real consumer as described above.

Affected: main, camel-4.23.x, camel-4.22.x, camel-4.18.x and camel-4.14.x. 
Verified by reading {{VertxWebsocketClientConsumer}} on each branch: the same 
{{setPeriodic}} / {{executeBlocking}} / connect / {{cancelTimer}} at lines 
59-62, and no {{doStop}}. Not run on the maintenance branches.

Duplicate check (2026-10-12): JIRA text "vertx-websocket reconnect" (1: 
CAMEL-23889, flaky tests), "VertxWebsocketClientConsumer" (1: CAMEL-20011, 
deprecated executeBlocking), "consumeAsClient" (1: CAMEL-20656, query 
parameters), "vertx-websocket reconnectInterval" (none), "vertx-websocket 
consumeAsClient duplicate" (none); GitHub pull requests "vertx-websocket 
reconnect": #24393 (merged, CAMEL-23889), #24740 (merged, CAMEL-24126), none 
open.

_Filed with Claude Code on behalf of allthingssecurity._




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

Reply via email to