[#794] Wait for the listen port to be open before returning from connection handler start - #796
Conversation
maximthomas
left a comment
There was a problem hiding this comment.
The fix is correct and I verified it by building and running, not just reading:
mvn installpasses;ERR_CONNHANDLER_TIMEOUT_WAITING_FOR_LISTENERgenerates asArg3<Object, Object, Number>, so(String, DN, long)type-checks.TestLDAPConnectionHandler→Tests run: 4, Failures: 0. WithLDAPConnectionHandler2reverted →testStartWaitsForListenPort:173 » Connect Connection refused, deterministic (rerunFailingTestsCount=3did not rescue it). Genuine regression test.GrizzlyLDAPListenerbinds synchronously in its constructor, so "startListener()returned" really does mean the port is listening — thefinallyplacement is the only one that closes the window.listenAttemptedis touched only under thewaitListenmonitor in both classes;wait(remainingMs)can never be reached with0. Clean.- Checked and benign:
startHttpServer()is now guaranteed to run beforehttpEndpointConfigManager.registerTo(...)(DirectoryServer.java:1548), where it previously raced.getHTTPRouter()returns the live mutableRouterandAbstractRouter.addRoute()callsnotifyDescriptorChange(), so late-registered endpoints still route and the API descriptor is rebuilt.
Three things to address before merge.
HTTP handler: an undocumented, untested second bug fix (major)
The description frames the HTTP change as consistency ("Same treatment for HTTPConnectionHandler, so LDAP, LDAPS, administration and HTTP ports are ordered consistently"). It is actually a second bug fix. Enabling the handler through dsconfig currently reports success while the port is still closed:
set-connection-handler-prop --handler-name "HTTP Connection Handler" \
--set listen-port:<free port> --set enabled:true
then connecting immediately, with no retry loop:
dsconfig returned after |
connect | |
|---|---|---|
| master | 976 ms | ConnectException: Connection refused |
| this PR | 1455 ms | OK |
That path is ConnectionHandlerConfigManager.applyConfigurationChange() → connectionHandler.start() (opendj-server-legacy/src/main/java/org/opends/server/core/ConnectionHandlerConfigManager.java:94,135).
The description also says DsconfigOptionsTestCase exercises the HTTP handler through dsconfig. It does not — testSetEnableHTTPConnectionHandler passes no --set at all:
// opendj-server-legacy/src/test/java/org/opends/server/tools/dsconfig/DsconfigOptionsTestCase.java:54
final String[] args =
{
"set-connection-handler-prop",
...
"--handler-name", "HTTP Connection Handler", // no --set, nothing is enabled
};It is the only file in the test tree that mentions the HTTP connection handler, and the handler is ds-cfg-enabled: false in opendj-server-legacy/resource/config/config.ldif:367. HTTPConnectionHandler.start() has no coverage at all.
Please add an HTTP counterpart to testStartWaitsForListenPort and say in the description that the HTTP path is a fix, not a cleanup. Note this also means #790 alone would not fix it — that PR keeps the notify before startHttpServer() and only closes the spurious-wakeup hole.
Per-handler timeout can exceed the start-ds timeout (medium)
startConnectionHandlers() starts handlers sequentially, each with its own 60 s budget:
// opendj-server-legacy/src/main/java/org/opends/server/core/DirectoryServer.java:3502
for (ConnectionHandler<?> handler : connectionHandlers)
{
handler.start();
}start-ds waits via WaitForFileDelete -t, default DirectoryServer.DEFAULT_TIMEOUT = 200 seconds. Default config enables LDAP + the always-started administration connector → 120 s worst case, inside budget. Enable LDAPS and HTTP as well and the bound becomes 240 s, i.e. start-ds reports a failed start for a server that does come up.
Only reachable if handler threads die before the listen code, but the cliff is easy to remove: one deadline shared across the loop, or 10–15 s per handler — ample for a bind().
New CodeQL java/non-sync-override alert (minor)
Thread.start() is synchronized, and neither DirectoryThread nor ConnectionHandler overrides it, so the new LDAPConnectionHandler2.start() is a third override of it. The repo already has two dismissed alerts for this rule, on exactly the sibling methods:
opendj-server-legacy/src/main/java/org/opends/server/protocols/ldap/LDAPConnectionHandler.java:814opendj-server-legacy/src/main/java/org/opends/server/protocols/http/HTTPConnectionHandler.java:571
both won't fix: "Deliberately left unsynchronized. The state touched is guarded by the waitListen monitor … holding the thread's own monitor across waitListen.wait() would block Thread.join()."
The new override needs the same dismissal. The HTTP one may resurface too, since that method body moved (571 → 588).
Nits
- Duplicated constant:
LISTEN_TIMEOUT_MS = 60 sis defined verbatim in both handlers and is not configurable. currentConfig.dn()in the timeout log: unreachable-null in practice (initializeConnectionHandleralways runs first), but it sits on an error path, and an NPE there escapesstart()intostartConnectionHandlers().ERR_vsWARN_: the message says startup will continue, which reads like a warning, but the key isERR_and it is logged at error level.- Test can't separate its two failure modes: at
opendj-server-legacy/src/test/java/org/opends/server/protocols/ldap/TestLDAPConnectionHandler.java:167, a failed bind and "start() returned too early" both surface asConnectException. Asserting the listener is up before connecting would split them;assertFalse(handler.isAlive())afterjoin(10000)would stop a stuck handler thread leaking silently into later tests in the same JVM. - Merge with #790: this PR supersedes #790's
HTTPConnectionHandlerhunk entirely (same field, same guard, same interrupt restore, plus the bounded wait and the moved notify) — resolve by taking this version of the file wholesale. It also closes 4 of #790's 25java/notify-instead-of-notify-allalerts, which sit on exactly the lines rewritten here (LDAPConnectionHandler2.java:704,722,HTTPConnectionHandler.java:628,649). Landing this first shrinks #790. - Legacy handler still uses
notify():LDAPConnectionHandler.java:876,900— briefly inconsistent if this lands before #790. - Follow-up: deferring the "
startConnectionHandlers()never checks whether the bind succeeded" fix is right, and this PR builds the mechanism for it — now thatstart()waits for the bind, reporting success is a return value rather than a redesign. Worth linking into #792.
| } | ||
|
|
||
| @Override | ||
| public void start() { |
… returning from connection handler start LDAPConnectionHandler2 declared a waitListen monitor but never waited on it, so DirectoryServer.startServer() could return while the LDAP, LDAPS and administration ports were still closed: a client connecting right afterwards got "Connection refused". HTTPConnectionHandler did wait, but notified before starting the embedded server, so its handshake had the same hole plus a spurious-wakeup window. Both handlers now set a listenAttempted predicate under the waitListen monitor after the bind attempt (in a finally block, so a failed bind unblocks startup too), and start() waits on that predicate. The wait is bounded to 60 seconds and logs an error on expiry, so a handler thread dying before it reaches the listen code cannot hang server startup. Adds TestLDAPConnectionHandler.testStartWaitsForListenPort, which connects to the handler port immediately after start() with no retry loop; it fails with "Connection refused" without the fix. Fixes OpenIdentityPlatform#794
…handler listen wait Adds HTTPConnectionHandlerTestCase.testStartWaitsForListenPort: HTTPConnectionHandler.start() had no coverage at all, and the change to it is a bug fix rather than a cleanup -- enabling the handler through dsconfig reported success while the port was still closed. The test fails with "the connection handler start method returned before port N was open" against the previous HTTPConnectionHandler. Moves LISTEN_TIMEOUT_MS to ConnectionHandler so both handlers share one definition, and lowers it from 60 to 15 seconds. startConnectionHandlers() starts handlers sequentially, so the old value let four enabled handlers spend 240 seconds in start(), past the 200 second start-ds timeout; 15 seconds keeps the worst case well inside it and is still generous for a bind(). Logs handlerName instead of friendlyName plus currentConfig.dn() on timeout: handlerName already carries the listen address and port, and nothing is dereferenced on that error path. Reworded the message so its tone matches the error severity it is logged at. TestCaseUtils.assertPortIsAcceptingConnections() replaces the bare connect in the LDAP test and is shared with the new HTTP test. It separates the two failure modes a plain ConnectException conflates -- the handler never bound, versus start() returned too early -- and both tests now assert the handler thread actually stopped after join(). Documents in both start() overrides why they are deliberately not synchronized, matching the dismissal rationale of the java/non-sync-override alerts on the sibling handlers.
33dab3d to
7417fa9
Compare
|
Thanks — all three addressed, plus the nits. Rebased onto HTTP handler: undocumented, untested second bug fix (major)You were right on both counts, and the claim about Added Verified the same way you did, by reverting each handler to its
Both deterministic — The description now states plainly that the HTTP change is a fix, shows the Per-handler timeout vs the
|
Fixes #794
Problem
org.forgerock.opendj.reactive.LDAPConnectionHandler2declares awaitListenmonitor whose documented purpose is to make server startup block until the listener is up, but nothing ever waits on it.ConnectionHandler.start()is therefore a plainThread.start(), andDirectoryServer.startServer()can return while the port is still closed — a client connecting immediately afterwards getsConnection refused.This is the handler that is actually used:
config.ldifconfigures LDAP and LDAPS withds-cfg-java-class: org.forgerock.opendj.reactive.LDAPConnectionHandler2, andAdministrationConnectorwraps the same class, so the administration port (4444) is affected as well —dsconfig,statusanddsreplicationright after a start can hit a closed port.start-dsinherits the race too:NOTE_DIRECTORY_SERVER_STARTEDis logged immediately afterstartConnectionHandlers().HTTPConnectionHandlerhas the same bug by a different route, and fixing it is the second fix in this PR, not a consistency cleanup. It does wait, but it notifies beforestartHttpServer(), sostart()returns while the embedded server is still coming up (and itswait()has no condition predicate, so a spurious wakeup returns fromstart()early). This is not only about server startup: enabling the handler throughdsconfigreports success while the port is still closed.Connecting immediately afterwards, with no retry loop, is refused on
masterand succeeds with this PR. That path isConnectionHandlerConfigManager.applyConfigurationChange()→connectionHandler.start()(opendj-server-legacy/src/main/java/org/opends/server/core/ConnectionHandlerConfigManager.java:94,135).Why the legacy pattern cannot be copied verbatim
org.opends.server.protocols.ldap.LDAPConnectionHandler(unused by the default configuration) implements the waiting side and notifies afterregisterChannels().LDAPConnectionHandler2notifies beforestartListener(), andGrizzlyLDAPListenerbinds every address synchronously in its constructor — so "the port is open" is equivalent to "startListener()returned". Setting the predicate at the existing notify site would only narrow the race window, not close it.Change
listenAttemptedpredicate guarded bywaitListen, set after the bind attempt in afinallyblock, so a failed bind unblocks startup exactly like the legacy handler's "attempted" semantics.start()overrides wait onwhile (!listenAttempted), guarding against spurious wakeups, and restore the interrupt flag.ERR_CONNHANDLER_TIMEOUT_WAITING_FOR_LISTENERon expiry: the bind now happens inside the waited region, and a handler thread dying before it reaches the listen code must not hang the whole server startup.ConnectionHandler.LISTEN_TIMEOUT_MSshared by both handlers, set to 15 seconds.startConnectionHandlers()starts handlers sequentially and each one spends up to that long instart(), so the worst case is 15 s × number of handlers — well inside the 200 sstart-dstimeout (DirectoryServer.DEFAULT_TIMEOUT) even with LDAP, LDAPS, administration and HTTP all enabled, and still generous for abind().HTTPConnectionHandler, so LDAP, LDAPS, administration and HTTP ports are all ordered consistently — see above for why that one is also a fix.start()overrides document why they are deliberately left unsynchronized, matching the won't fix rationale of the existingjava/non-sync-overridedismissals on the sibling handlers.Test
Each handler gets a
testStartWaitsForListenPortthat connects to the listen port immediately afterstart()returns, with no retry loop:masterTestLDAPConnectionHandlerTests run: 4, Failures: 0AssertionError: the connection handler start method returned before port N was openHTTPConnectionHandlerTestCase(new)Tests run: 1, Failures: 0AssertionError: the connection handler start method returned before port N was openBoth failures are deterministic —
rerunFailingTestsCount=3does not rescue them.HTTPConnectionHandler.start()had no test coverage at all before this PR;HTTPConnectionHandlerTestCaseis the first test to start that handler.TestCaseUtils.assertPortIsAcceptingConnections()is shared by both tests. It separates the two failure modes a bareConnectExceptionconflates: the handler never managed to bind, versusstart()returned before the port was open. Both tests also assert that the handler thread actually stopped afterjoin(), so a stuck thread cannot leak silently into later tests in the same JVM.DsconfigOptionsTestCasealso still passes (Tests run: 7, Failures: 0).Notes
0885d16), which touched the same lines. This PR supersedes Fix CodeQL warning-severity alerts: missed wakeups, resource leaks, escaping threads #790'sHTTPConnectionHandlerhunk — same field, same guard, same interrupt restore, plus the bounded wait and the moved notify — and closes 4 of itsjava/notify-instead-of-notify-allalerts, on the lines rewritten here.LDAPConnectionHandler2.start()raises ajava/non-sync-overridealert. It needs the same won't fix dismissal as the two existing ones onLDAPConnectionHandler.javaandHTTPConnectionHandler.java:synchronizedwould add no safety here (the state is guarded bywaitListen, and the already-started check belongs tosuper.start()) while holding the thread's own monitor acrosswaitListen.wait()would blockThread.join().startConnectionHandlers()still does not check whether the bind succeeded — the server reports a successful start even when a port could not be opened. That is a separate follow-up, in the spirit of Flaky test: AssuredReplicationServerTest#testSafeDataFromRS — RS silently starts without a listener when the replication port is briefly occupied #792, and this PR builds the mechanism for it: now thatstart()waits for the bind, reporting success becomes a return value rather than a redesign.org.opends.server.protocols.ldap.LDAPConnectionHandler.start()still waits unbounded. It is unused by the default configuration and untouched here; bounding it with the same shared constant is a small follow-up.