[#109] Serialize WebSocket message dispatch per socket to prevent out-of-order processing#110
Conversation
…#109) Messages of one socket were handed to a shared unbounded pool with no ordering, so a message could be processed before the handshake that arrived just ahead of it. Replace the interim awaitHandshake wait with a per-socket serial dispatch queue draining onto the shared pool: messages of one socket are processed in arrival order, different sockets still run concurrently. OperationRequest execution is re-submitted to the pool so long-running operations keep their concurrency and CancelOpRequest stays timely. Also serialize outgoing frames in the Jetty connector server: sends went through a 10-thread pool per socket, which could reorder frames on the wire and leaked the pool on close.
maximthomas
left a comment
There was a problem hiding this comment.
The inbound half (executeSerially + OpenICFServerAdapter) looks correct — I walked the CAS interleavings and found no defect, and the tests are well-targeted. One blocker in the Jetty half.
Blocker: sendExecutor.shutdown() bricks the server after the first disconnect
OpenICFWebSocketCreator caches one SinglePrincipal per principal name and reuses it for every connection of that name (OpenICFWebSocketCreator.java:129-136). With the default anonymous authenticator that's a single instance for the whole server.
sendExecutor is a field of that instance, but onWebSocketClose is a per-connection callback. So:
- Client #1 disconnects →
sendExecutor.shutdown() - Client #2 connects → same cached principal, dead executor
- Every send →
RejectedExecutionException→ silently masked by the new catch asConnectorIOException("Socket is not connected.")
Sending is permanently disabled for all subsequent clients until restart.
Verified by running connect → close → reconnect against the actual code:
| Tree | Result |
|---|---|
master abc8e325 |
PASS |
this PR 3ddeae15 |
FAIL — ConnectorIOException: Socket is not connected. |
| this PR + fix below | PASS (jetty suite 31/31) |
No existing test in connector-server-jetty/src/test closes and reconnects, which is why CI is green. Repro test attached below.
Fix — shut down at the principal's real lifecycle end instead of per connection:
// onWebSocketClose: drop the sendExecutor.shutdown() line
protected void doClose() {
sendExecutor.shutdown();
}ConnectionPrincipal.close() calls doClose() under an isRunning CAS, so it fires exactly once when the principal actually goes away.
Two corrections to the description
- Jetty's
RemoteEndpointis not non-thread-safe. In 11.0.25sendBlockinguses a per-callFutureCallbackinto a thread-safe FIFOFrameFlusher. Concurrent sends were unordered, never unsafe. The reordering argument alone justifies the change — the fix is right, the stated reason isn't. - The old pool leaked 10 threads per principal, not per connection (same caching as above), so the leak being fixed is much smaller than described.
Minor
executeSeriallyrelies on the container deliveringonWebSocketBinarysequentially per connection. True for Jetty and Grizzly, but load-bearing and unstated — worth a line in the javadoc.- The test comment says the unsynchronized
ArrayListis safe because tasks never overlap. Non-overlap alone doesn't give visibility; it works via the release/acquire chain throughdispatchScheduled. Worth correcting so a future refactor doesn't silently break the premise. - Nothing covers the
RejectedExecutionExceptionpath inscheduleDispatch— the trickiest state in the new code, and a wedged queue if the flag reset were ever dropped. Cheap test: reject once, then submit with a live executor and assert both tasks drain in order. Executors.newSingleThreadExecutor()gives an unnamed non-daemonpool-N-thread-1;Utils.newThreadFactoryis already on the classpath here.- Pre-existing, not from this PR:
CancelOpRequestcan still arrive before the operation registers itself (registration happens inLocalRequest's constructor, on the pool thread). Worth a follow-up.
Repro test
package org.forgerock.openicf.framework.server.jetty;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import org.eclipse.jetty.websocket.api.RemoteEndpoint;
import org.eclipse.jetty.websocket.api.Session;
import org.forgerock.openicf.framework.remote.rpc.OperationMessageListener;
import org.forgerock.openicf.framework.remote.rpc.WebSocketConnectionGroup;
import org.forgerock.openicf.framework.remote.rpc.WebSocketConnectionHolder;
import org.testng.Assert;
import org.testng.annotations.Test;
/**
* Does a SinglePrincipal that has seen one connection closed still send on a
* subsequent connection? OpenICFWebSocketCreator caches SinglePrincipal per
* principal name, so the same instance serves every connection of that name.
*/
public class ReconnectSendTest {
private static final AtomicInteger SENT = new AtomicInteger();
private static Session newSession() {
final RemoteEndpoint remote = (RemoteEndpoint) Proxy.newProxyInstance(
ReconnectSendTest.class.getClassLoader(),
new Class<?>[] { RemoteEndpoint.class },
new InvocationHandler() {
public Object invoke(Object p, Method m, Object[] a) {
if ("sendBytes".equals(m.getName())) {
SENT.incrementAndGet();
}
return null;
}
});
return (Session) Proxy.newProxyInstance(
ReconnectSendTest.class.getClassLoader(),
new Class<?>[] { Session.class },
new InvocationHandler() {
public Object invoke(Object p, Method m, Object[] a) {
if ("isOpen".equals(m.getName())) {
return Boolean.TRUE;
}
if ("getRemote".equals(m.getName())) {
return remote;
}
return null;
}
});
}
@Test(timeOut = 30000)
public void testSendWorksAfterReconnectOnCachedPrincipal() throws Exception {
final AtomicReference<WebSocketConnectionHolder> holder =
new AtomicReference<WebSocketConnectionHolder>();
OperationMessageListener capturing = (OperationMessageListener) Proxy.newProxyInstance(
ReconnectSendTest.class.getClassLoader(),
new Class<?>[] { OperationMessageListener.class },
new InvocationHandler() {
public Object invoke(Object p, Method m, Object[] a) {
if ("onConnect".equals(m.getName())) {
holder.set((WebSocketConnectionHolder) a[0]);
}
return null;
}
});
SinglePrincipal principal = new SinglePrincipal("anonymous", capturing, null,
new ConcurrentHashMap<String, WebSocketConnectionGroup>());
// --- connection #1 ---
principal.onWebSocketConnect(newSession());
Future<?> first = holder.get().sendBytes(new byte[] { 1, 2, 3 });
first.get(5, TimeUnit.SECONDS);
Assert.assertEquals(SENT.get(), 1, "first connection should have sent");
principal.onWebSocketClose(1000, "client went away");
// --- connection #2 on the SAME cached principal instance ---
principal.onWebSocketConnect(newSession());
Future<?> second = holder.get().sendBytes(new byte[] { 4, 5, 6 });
second.get(5, TimeUnit.SECONDS);
Assert.assertEquals(SENT.get(), 2,
"send after reconnect must reach the wire");
}
}…ew findings SinglePrincipal is cached per principal name and serves every connection of that name, but sendExecutor.shutdown() ran in the per-connection onWebSocketClose: the first disconnect killed the executor and every send on a later connection of the same principal failed as "Socket is not connected." until restart. Shut the executor down in doClose() instead and create its thread named and daemon via Utils.newThreadFactory, since the servlet lifecycle never closes cached principals. Also from the review: correct the RemoteEndpoint comment (it is thread-safe; the defect was frame reordering), document the sequential-callback assumption of executeSerially, credit the test's unsynchronized list to the release/acquire chain through dispatchScheduled, cover the RejectedExecutionException path, add a reconnect repro test, and add two @OverRide annotations flagged by CodeQL.
Fixes #109. Follow-up to #107/#108.
Problem
OpenICFServerAdapter.onMessagehanded every incoming WebSocket message to a shared unbounded cached thread pool with no per-socket ordering, so two messages delivered in order on one socket could be processed out of order. #108 papered over the most visible instance (an operation response overtaking the handshake response on a fresh socket) with a 500 msawaitHandshakepoll.Change
WebSocketConnectionHolder.executeSerially: per-socket serial dispatch queue draining onto the shared pool — at most one in-flight task per socket, messages processed strictly in arrival order, different sockets still run concurrently. Lives on the holder, so its lifecycle is the socket's. Arrival ordering relies on the container invoking per-connection callbacks sequentially (both Jetty and Grizzly do) — now stated in the javadoc.OpenICFServerAdapter: dispatches via the serial queue;OperationRequestexecution is re-submitted to the shared pool after ordered classification, so long-running operations keep their concurrency over one socket andCancelOpRequeststays timely. TheawaitHandshakepoll and its helpers are removed.SinglePrincipal: outgoing frames went through a 10-thread pool, so concurrent blocking sends could reach the wire in any order (a handshake response could be overtaken before ever reaching the network). Jetty'sRemoteEndpointitself is thread-safe — the defect was reordering, not thread-unsafety. Replaced with a single named daemon sender thread.SinglePrincipalis cached per principal name byOpenICFWebSocketCreatorand serves every connection of that name, so the executor is a per-principal resource: it is shut down indoClose()(the principal's lifecycle end), not in the per-connectiononWebSocketClose— and the pool being replaced accordingly leaked 10 threads per principal, not per connection.The
responseQueuere-sorting inBatchApiOpImpland theResultBuffersequence handling stay:RemoteConnectionGroup.trySendMessageround-robins across a group's sockets, so cross-socket ordering is never guaranteed at the protocol level.Review fixes (thanks @maximthomas)
sendExecutor.shutdown()moved fromonWebSocketClose(per-connection) todoClose()(per-principal): with the cached principal, the first disconnect used to kill the executor and permanently disable sending for every later connection of that name.Utils.newThreadFactory(named, daemon) — nothing closes cached principals in the servlet lifecycle, so a non-daemon thread would outlive servletdestroy().RemoteEndpointthread-safety claim and the leak scope in the description and code comments.executeSeriallyjavadoc states the sequential-callback assumption; the test comment now credits visibility to the release/acquire chain throughdispatchScheduled, not to non-overlap alone.RejectedExecutionException-path test and the reconnect repro test; added the two@Overrides flagged by CodeQL.Tests
WebSocketConnectionHolderTest: 1000-task submission order and no-overlap check, cross-socket independence (a blocked socket does not stall others), queue survival after a throwing task, and a rejected dispatch not wedging the queue.ReconnectSendTest(jetty): a send after close + reconnect on the same cachedSinglePrincipalmust still reach the wire.connector-framework-server29/29,connector-server-grizzly32/32,connector-server-jetty31/31;OpenICFWebSocketTestrun three extra times — stable.