Conversation
maximthomas
left a comment
There was a problem hiding this comment.
praise: The promise now exists for as long as the request is registered, so every cancellation path has something to cancel.
RemoteRequest.promiseisfinaland built in the constructor together with the completion callback (RemoteRequest.java:59,:95), so a request cancelled before its send unregisters itself.apply()re-checkspromise.isDone()under the lock (RemoteRequest.java:197), andtryCancelnotifies the remote only for a delivered message (:83), so the cancel no longer overtakes the request.groupShutdownBeforeSendCancelsPromiseWithoutSendingAnythingfails at the base, which pins the #124 NPE on theshutdown()loop itself.
suggestion (non-blocking): No test pins the isDone() re-check under the send lock.
OpenICF-java-framework/connector-framework-rpc/src/main/java/org/forgerock/openicf/common/rpc/RemoteRequest.java:187, :197
cancelBeforeSendCancelsPromiseWithoutSendingAnything and groupShutdownBeforeSendCancelsPromiseWithoutSendingAnything cancel while createMessageElement blocks, before apply() runs, so the fast path at :187 returns first. The re-check at :197 is what stops a cancel landing between :187 and tryLock. Changing :197 to !isSent() keeps connector-framework-rpc green (19/19). With :187 also reduced to isSent(), both cases fail with the request on the wire (sent.size() == 2).
public Promise<V, E> apply(H remoteConnectionHolder) throws Exception {
if (isSent()) {
return promise;
}Pin: with the fast path reduced to isSent(), the two cases above go red whenever the re-check at :197 is dropped (measured).
suggestion (non-blocking): No test pins remoteCancelSent, so a double CancelOpRequest passes the suite.
OpenICF-java-framework/connector-framework-rpc/src/main/java/org/forgerock/openicf/common/rpc/RemoteRequest.java:162-166, OpenICF-java-framework/connector-framework-rpc/src/test/java/org/forgerock/openicf/common/rpc/RemoteRequestCancelBeforeSendTest.java:129-173
Each case drives only one of the two callers of notifyRemoteCancelOnce() (:85 or :211). Replacing remoteCancelSent.compareAndSet(false, true) with true keeps connector-framework-rpc green (19/19). PromiseImpl.cancel checks isDone() without a lock, so two cancel(true) calls after the send both reach tryCancel, and that is enough to make the guard observable:
@Test
public void concurrentCancelsAfterSendNotifyRemoteOnce() throws Exception {
RecordingHolder holder = new RecordingHolder(group, false);
group.addConnection(holder);
BlockingRequestFactory factory = new BlockingRequestFactory();
factory.releaseSend();
TestRemoteRequest<RecordingHolder> request = group.trySubmitRequest(factory);
Assert.assertNotNull(request);
holder.blockFirstSend(); // the next send - the first cancel message - blocks
Future<Boolean> first = executor.submit(() -> request.getPromise().cancel(true));
holder.awaitBlockedInSend();
// The first cancel is still inside tryCancel: the promise is not done,
// so this one reaches notifyRemoteCancelOnce() too.
request.getPromise().cancel(true);
holder.releaseSend();
first.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
Assert.assertTrue(request.getPromise().isCancelled());
Assert.assertEquals(holder.sent.size(), 2, "request then one cancel: " + holder.sent);
}Pin: the true mutant sends a third message, which turns this case red. Not run.
question (non-blocking): Should a late cancel that cannot be delivered leave the send function's result alone and return the cancelled promise, as the tryCancel path does?
OpenICF-java-framework/connector-framework-rpc/src/main/java/org/forgerock/openicf/common/rpc/RemoteRequest.java:207-212, :84-88
WebSocketConnectionGroup.shutdown() can cancel(true) a request whose sender is still in sendBytes(...).get(), then close the group and drop its sockets. Once that send completes, notifyRemoteCancelOnce() at :211 runs outside any try. RemoteOperationRequest.trySendBytes finds no socket and throws ConnectorIOException. RemoteConnectionGroup.trySendMessage counts that as a failed connection and, with no other connection left, returns null. trySubmitRequest then deregisters the request and returns null, although the request was delivered and cancelled. The caller gets FAILED_EXCEPTION from AbstractAPIOperation.submitRequest, or ConnectionPrincipal.trySubmitRequest moves on to the next operational group. This is Minor as it stands, because it needs shutdown() to race an in-flight send. It would be Major if a deployment you support can resend a delivered request to another operational group this way.
requestTime = System.currentTimeMillis();
if (remoteCancelRequested) {
// Cancelled while the message was on its way:
// tryCancel saw it unsent. The promise is already
// cancelled; a cancel that cannot be sent must not
// fail the delivered request.
try {
notifyRemoteCancelOnce();
} catch (final Throwable ignored) {
// The transport is gone - nothing left to tell.
}
}Pin: add a BlockingRequestFactory request whose tryCancelRemote throws, and cancel it with cancel(true) while holder.blockFirstSend() holds the send. At head trySubmitRequest returns null; with the fix it returns the request with a cancelled promise. Not run.
…uction so an unsent request can be cancelled RemoteRequest was registered in RemoteConnectionGroup.remoteRequests before its promise existed: the promise was created inside the send function and reset to null when a send failed. Cancelling such a request - from WebSocketConnectionGroup.shutdown(), submitRequestCancel() or cancel() - threw NullPointerException from getPromise().cancel(...). On the client it escaped ClientRemoteConnectorInfoManager.doClose() after isRunning had been flipped, so the group, the private WebSocket connections and the close listeners (ConnectionManager's registry entry) were never cleaned up. The promise is now created in the constructor and getPromise() never returns null. The send function does not send a request whose promise is already done, so a request cancelled before it was sent hands its caller the cancelled promise instead of an answer that never arrives; a failed send keeps the same promise for the next connection. tryCancel(true) notifies the remote side only for a delivered message, and a cancel that races the send in progress is delivered after the request rather than ahead of it. Fixes OpenIdentityPlatform#124
…able late cancel The send function's fast path now checks only isSent(), so a request cancelled before its send reaches the isDone() check under the lock and the existing cancel-before-send tests pin it. A cancel that races the send in progress and cannot be delivered afterwards no longer fails the delivered request: trySubmitRequest returned null for it, and ConnectionPrincipal moved on to the next group. Tests pin remoteCancelSent (two concurrent cancel(true) calls send one cancel message) and the undeliverable late cancel. Adds @OverRide to the anonymous-class methods CodeQL flagged.
bc65c6a to
9ba0594
Compare
|
@maximthomas all three are taken in 9ba0594, rebased onto current 1. 2. 3. Late cancel that cannot be delivered. Yes. This is a regression in this PR: the old code called Also added connector-framework-rpc 21 tests, connector-framework-server 30 (with #129's |
Fixes #124
Problem
RemoteConnectionGroup.allocateRequestregisters aRemoteRequestinremoteRequestsbefore it is sent, butRemoteRequest.promiseonly came into existence inside the send function (getSendFunction()→apply(holder)) and was reset tonullwhen a send failed. For the whole window between registration and a successful sendgetPromise()returnednull, and every cancellation path dereferenced it unguarded:WebSocketConnectionGroup.shutdown(),RemoteConnectionGroup.submitRequestCancel,RemoteRequest.cancel(), plusRemoteOperationRequest.check()throughgetExceptionHandler().On the client the
NullPointerExceptionescapedClientRemoteConnectorInfoManager.doClose()afterConnectionPrincipal.close()had already flippedisRunning, so the group'sdelegate.close(), the private WebSocket connections,globalConnectionGroups.removeand the close listeners (ConnectionManager'sregistry.remove(info)) were all skipped, and a secondclose()was a no-op. The server side has the same loop inOpenICFWebSocketApplication.close()/OpenICFWebSocketCreator.close(), where the NPE would stop the remaining groups from shutting down.The window became reachable from user code with #104, which moved the initial
CONNECTOR_INFOrequest fromhandshake()(beforeconnectPromiseresolves) tohandshakeComplete()(after it):connect().getOrThrow()now returns while that request is registered but not yet sent, andclose()from the caller's thread runs concurrently with the send.Change
RemoteRequestonly:final;getPromise()never returnsnull. The completion callback (removal fromremoteRequests) is registered there too -PromiseImplfires it on cancellation as well, so a request cancelled before it was sent unregisters itself.trySubmitRequestreturns the request,getOrThrowthrows the cancellation exception) instead of an answer that can never arrive.trySendMessageretries on its next connection with the same promise.tryCancel(true)sendsCancelOpRequestonly for a delivered message, so a request cancelled before the send puts nothing on the wire. A cancel racing a send in progress is caught by a store-then-load pair on both sides (remoteCancelRequested/requestTime, deduplicated byremoteCancelSent): the cancel message is sent after the request, once, instead of ahead of it. If that late cancel message cannot be sent (the group's sockets are already gone), the delivered request is still reported as sent, with its promise cancelled.tryLocknot acquired within a minute now throwsIllegalStateExceptioninstead of returning an unsent promise.Tests
RemoteRequestCancelBeforeSendTest(connector-framework-rpc), deterministic through a request that blocks increateMessageElementand a holder that blocks insendString:promiseExistsWhileRequestIsRegisteredButNotSent,cancelBeforeSendCancelsPromiseWithoutSendingAnything(viasubmitRequestCancel),groupShutdownBeforeSendCancelsPromiseWithoutSendingAnything(theshutdown()loop) andcancelDuringSendNotifiesRemoteAfterTheRequestIsDeliveredfailed before the change - the first three with the exact NPE /nullfrom the issue, the last one with the cancel message overtaking the request.concurrentCancelsAfterSendNotifyRemoteOnce: two concurrentcancel(true)after the send put one cancel message on the wire (pinsremoteCancelSent); before the change each sent its own.cancelAfterSendNotifiesRemote,sendFailsOverToTheNextConnectionAndCompletesNormallyandundeliverableCancelDuringSendKeepsTheDeliveredRequest(a cancel during the send whosetryCancelRemotethrows:trySubmitRequeststill returns the delivered request) pin down behaviour that had to survive the change; they pass before and after.Local runs: connector-framework-rpc 21 tests, connector-framework-server 30, connector-server-grizzly 34, connector-server-jetty 34, all green.
Compatibility
getPromise()of a registered request is a pending promise before the send rather thannull; a request cancelled before its send is returned bytrySubmitRequestwith a cancelled promise rather than sent. Both are visible only to code that raced the send, which previously got the NPE.Noticed, not changed
handshakeComplete()retries the initialCONNECTOR_INFOrequest on any failure, including the cancellation fromshutdown(), and the retry goes out over the still-open socket beforeprincipalIsShuttingDown()removes it. Pre-existing and independent of this change: handshakeComplete() resends the initial CONNECTOR_INFO request while the group is shutting down #125, fixed in [#125] Do not resend the initial CONNECTOR_INFO request when shutdown() cancelled it #129.WebSocketConnectionGroup.principalsis a plainTreeSetwritten from handshake threads and fromprincipalIsShuttingDown()/close()without synchronisation.