Notify connection event listeners when an internal connection is closed - #787
Conversation
InternalConnection kept a CopyOnWriteArrayList of ConnectionEventListener that addConnectionEventListener() and removeConnectionEventListener() maintained, but nothing ever iterated: an internal connection never told its listeners anything. isClosed() and isValid() were hardcoded to false and true, each carrying a FIXME saying they should track the close. Replace the list with the existing ConnectionState SPI class, which already implements the VALID -> CLOSED state machine together with the listener notifications, and whose own comment asks for it to be reused by connection implementations. close() now notifies the listeners and forwards to the server connection only on the first call, which also makes it idempotent as Connection.close() requires, and isClosed() and isValid() delegate to the state, resolving both FIXMEs. A listener registered after the connection was closed is notified straight away, matching ConnectionState and GrizzlyLDAPConnection. Connection errors and unsolicited notifications are deliberately not wired up: an internal connection has no transport, so closure by the application is the only event its listeners can observe. Adds InternalConnectionTestCase; four of its five tests fail against the previous code.
maximthomas
left a comment
There was a problem hiding this comment.
Reusing ConnectionState here is the right call, and I reproduced every number in the description: InternalConnectionTestCase is 5/5 green on the branch and 4/5 red with the production file reverted (same lines, same messages); opendj-core 8166/0, opendj-config 544/0, opendj-rest2ldap 531/0. I also ran opendj-grizzly ConnectionFactoryTestCase (77 invocations, 0 failures) since it builds internal connections via Connections.newInternalConnectionFactory — worth adding to the matrix.
Two blocking issues, both confirmed by running the patched class against master.
Listener that de-registers itself breaks close (high)
ConnectionState iterates a LinkedList with a for-each while holding its own monitor, and removeConnectionEventListener is reentrant on that monitor. Driving Connections.newInternalConnection both ways:
| Scenario | master | this PR |
|---|---|---|
| 2 listeners, first removes itself | forwarded | 2nd listener silently never notified, forwarded |
| 4 listeners, first removes itself | forwarded | ConcurrentModificationException out of close(), 1 of 4 notified, server never told |
…retry close() |
forwards again | still never told — state is already CLOSED, unrecoverable |
Self-deregistration on close is a normal idiom — opendj-core/src/main/java/org/forgerock/opendj/ldap/LDAPConnectionFactory.java:900 does exactly removeConnectionEventListener(this) inside its own handleConnectionClosed().
Two lines in opendj-core/src/main/java/org/forgerock/opendj/ldap/spi/ConnectionState.java fix it for every connection implementation, not just this one:
-import java.util.LinkedList;
import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
...
- private final List<ConnectionEventListener> listeners = new LinkedList<>();
+ private final List<ConnectionEventListener> listeners = new CopyOnWriteArrayList<>();Verified: 2/2 and 4/4 listeners notified, no CME, server told exactly once.
Listener exception skips the server-side close (high)
Same failure mode, simpler trigger. With this PR a RuntimeException from a listener propagates out of close() and serverConnection.handleConnectionClosed(...) is never called — on master the forward always happened. It escapes try-with-resources too, and that reaches production code: OpenDJProvider.newLDIFKeyStore builds a newInternalConnectionFactory, and opendj-core/src/main/java/org/forgerock/opendj/security/KeyStoreImpl.java closes those connections in six try (final Connection ...) blocks.
This matters more here than for LDAPConnectionFactory, where a separate connectionImpl.close() still runs. For an internal connection the forward is the only cleanup — RequestHandlerFactoryAdapter.doClose() is what cancels pending requests.
In opendj-core/src/main/java/org/forgerock/opendj/ldap/InternalConnection.java:
private final AtomicBoolean isClosed = new AtomicBoolean();
@Override
public void close(final UnbindRequest request, final String reason) {
// Closing an already closed connection has no effect.
if (isClosed.compareAndSet(false, true)) {
try {
state.notifyConnectionClosed();
} finally {
// A misbehaving listener must not prevent the server connection
// from releasing its resources.
serverConnection.handleConnectionClosed(messageID.getAndIncrement(), request);
}
}
}Verified with both fixes applied: server told exactly once in every scenario, including the throwing-listener case. Baseline, 16-thread concurrent close (exactly 1 notification, 1 forward) and re-entrant close() from a listener all still behave. The exception still propagates — no notify site in the codebase guards individual callbacks, so that's the existing convention; the finally is the part that matters.
Nits
- Alert reference:
#692is accurate — alert 692 isjava/unused-containeratInternalConnection.java:52— but GitHub auto-links it to issue #692, an unrelated closedimport-ldifbug. "code-scanning alert #692" removes the trap. - Pooling: no impact, in case it came up. I built a
CachedConnectionPoolover an internal connection factory and it behaves identically before/after — the pooled wrapper returns the delegate to the pool rather than closing it, soisValid()never flips. - Stale javadoc:
Connection.addConnectionEventListener(opendj-core/src/main/java/org/forgerock/opendj/ldap/Connection.java:287) still documents@throws IllegalStateException. Following the implementations is right, buttestListenerAddedAfterCloseIsNotifiedImmediatelynow codifies the divergence — worth a follow-up to fix the javadoc. - Missing tests: a self-deregistering-listener test and a throwing-listener test would each catch one of the issues above; a concurrent-close test would pin the idempotency claim.
verifyClosedOnce(...)is asserted in only two of five tests. - Loose matcher:
any(UnbindRequest.class)passes even if a substituted request is forwarded; asserting the same instance is tighter. - Pre-existing, not yours: an operation issued after
close()blocks forever (jstack showsmainparked inPromiseImpl.await), identical on master. Good argument for the deferred operation-level state checks — they'd turn an infinite hang into anIllegalStateException.
ConnectionState iterated its LinkedList of listeners while holding its own monitor, and removeConnectionEventListener() is re-entrant on that monitor. A listener de-registering itself from its own callback - the idiom LDAPConnectionFactory uses - therefore silently skipped the remaining listeners with two listeners registered, and threw ConcurrentModificationException out of the notification loop with three or more. Switch the listener list to CopyOnWriteArrayList, which fixes the close, error and unsolicited notification loops at once, for every connection implementation built on this class. For an internal connection the consequence went beyond a lost notification: the state is set to CLOSED before the listeners are notified, so an exception escaping the loop left ServerConnection.handleConnectionClosed() uncalled while a retried close() was already a no-op - the forward is the only cleanup an internal connection performs, and RequestHandlerFactoryAdapter.doClose() is what cancels the pending requests. Guard the hand-over with an AtomicBoolean and forward it from a finally block, so a misbehaving listener cannot skip it. The exception still reaches the caller, as it does at every other notification site in the SDK. Drop the @throws IllegalStateException from the javadoc of Connection.addConnectionEventListener() and its ConnectionState counterpart: no implementation throws it, they all notify a listener registered after the event immediately, which is what the tests pin down. Adds a test for each failure mode at both levels plus a 16-thread concurrent close, and tightens the existing assertions to check the forwarded unbind request instance rather than any request. All four new tests fail against the previous code.
|
Thanks — both blocking issues confirmed and fixed in 4f14465, along with every nit. Reproduced your findings before touching anything. Listener that de-registers itself breaks closeConfirmed, and the root cause is where you point: One refinement to the table: the threshold is three, not four. Applied your fix — Listener exception skips the server-side closeConfirmed and applied as you wrote it, It does mean the closed-ness lives in two places now ( Nits
VerificationWith only the two fixes reverted, the four new tests fail and nothing else does: With them applied: Agreed on leaving the operation-after-close hang alone: it is identical on master and belongs with the operation-level state checks. |
Clears the
java/unused-containercode scanning alert 692 — "The contents of this container are never accessed".Problem
InternalConnectionmaintained a list of connection event listeners that nothing ever read:addConnectionEventListener()added to it andremoveConnectionEventListener()removed from it, but no code path ever iterated it. An internal connection therefore never told its listeners anything, silently breaking the documented contract ofConnection.addConnectionEventListener():The same area carried two related defects, each already marked in the source:
And
close()forwarded to the server connection on every call, whereasConnection.close()specifies that "calling close on a connection that is already closed has no effect".Fix
Rather than hand-rolling the notification, this reuses
org.forgerock.opendj.ldap.spi.ConnectionState, which already implements theVALID -> CLOSED / ERRORstate machine together with the listener bookkeeping — and whose own comment asks for exactly this:addConnectionEventListenerstate.addConnectionEventListener(...)removeConnectionEventListenerstate.removeConnectionEventListener(...)close(UnbindRequest, String)serverConnection.handleConnectionClosed(...)isClosed()return false;+ FIXMEstate.isClosed()isValid()return true;+ FIXMEstate.isValid()close()is now idempotent, as the interface requires, and both FIXMEs are resolved.handleConnectionErrorandhandleUnsolicitedNotificationare not wired up. An internal connection has no transport, so it can neither fail nor receive an unsolicited notification; closure by the application is the only event its listeners can observe. This is documented on the new field.Operation-level state checks (throwing
IllegalStateExceptionfromaddAsyncand friends once the connection is closed) are intentionally left out — that is a separate contract change, beyond the scope of this alert.Review follow-ups
Making
InternalConnectionnotify its listeners exposed two defects in the notification path. Both are fixed here.A listener may de-register itself while being notified
ConnectionStateiterated itsLinkedListof listeners while holding its own monitor, andremoveConnectionEventListener()is re-entrant on that monitor. A listener de-registering itself from its own callback — the idiomLDAPConnectionFactoryuses inhandleConnectionClosed()— broke the loop:ConcurrentModificationExceptionout ofclose()For an internal connection the exception escaped before
serverConnection.handleConnectionClosed(...), and since the state was alreadyCLOSEDa retriedclose()was a no-op — the server was never told, and the pending requests were never cancelled.The listener list is now a
CopyOnWriteArrayList, which fixes the close, error and unsolicited notification loops at once, for every connection implementation built onConnectionState.A misbehaving listener must not skip the server-side close
Same shape, simpler trigger: a
RuntimeExceptionfrom a listener also escapedclose()before the forward. That matters more here than forLDAPConnectionFactory, where a separateconnectionImpl.close()still runs — for an internal connection the forward is the only cleanup there is, andRequestHandlerFactoryAdapter.doClose()is what cancels the pending requests. It reaches production code:OpenDJProvider.newLDIFKeyStorebuilds anewInternalConnectionFactory, andKeyStoreImplcloses those connections in six try-with-resources blocks.The hand-over is now guarded by an
AtomicBooleanand forwarded from afinallyblock, so it happens exactly once even if the notification fails. The exception still reaches the caller, as it does at every other notification site in the SDK.Stale javadoc
Connection.addConnectionEventListener()and itsConnectionStatecounterpart documented an@throws IllegalStateExceptionfor a listener registered after the connection was closed. No implementation throws it —ConnectionStateandGrizzlyLDAPConnectionboth notify the late listener immediately — so the javadoc now describes what the code does, which is also whattestListenerAddedAfterCloseIsNotifiedImmediatelypins down.Testing
InternalConnectionTestCase— 8 tests covering close notification, close idempotency, listener removal, late registration, theisClosed()/isValid()transition, a self-de-registering listener, a throwing listener, and a 16-thread concurrent close. The close assertions check that the veryUnbindRequestthe caller passed is the one forwarded.ConnectionStateTest— 2 tests for a listener de-registering itself during the close and during the error notification.Run with the original
InternalConnectionrestored, the tests covering the alert itself fail:Run with only the two review fixes reverted, the tests covering them fail:
testRemovedListenerIsNotNotifiedpasses both before and after — with the old code nobody was notified at all, so it is a characterisation test.Regression runs across the modules that use internal connections or
ConnectionState:opendj-coreopendj-grizzly—GrizzlyLDAPConnectionbuilds onConnectionStateopendj-config—LDAPManagementContextbuilds onnewInternalConnectionopendj-rest2ldap