Fix CodeQL warning-severity alerts: missed wakeups, resource leaks, escaping threads - #790
Fix CodeQL warning-severity alerts: missed wakeups, resource leaks, escaping threads#790vharseko wants to merge 2 commits into
Conversation
…scaping threads Tier 2 of the warning-severity CodeQL triage: 66 of the 73 alerts in these four rules are fixed, the remaining 7 are intentional and explained below. java/notify-instead-of-notify-all (25 alerts, all fixed) Every waiter re-checks its condition in a loop, so notifyAll() is safe at each site. The conversion fixes genuine missed wakeups where several threads can wait on one monitor: * NodeSearcherQueue: BrowserController creates the queue with two consumer threads, so waking a single one could leave queued work unattended. * ReplicationBroker.connectPhaseLock: every thread calling publish() can wait. * ReplicationBroker.monitorResponse: several monitor requests can be pending. * LDAP, HTTP and reactive connection handlers: waitListen. The other sites have a single waiter by construction and are hardened for consistency. MessageHandler.shutdown() called notify() immediately before an existing notifyAll(); the redundant call is removed. java/input-resource-leak and java/output-resource-leak (31 alerts, 26 fixed) * Never closed at all: GenerateGlobalAcisTableMojo, Utils.supportsOption, ConfigureWindowsService, the Server and ModifyAsync examples. * Handed to a background reader thread and then dropped: the StopReader and StartReader of ServerController, and OutputReader. The reader is now closed by the thread that drains it. * ZipExtractor validated the .zip extension after delegating to the constructor which opens the file, leaking the stream when validation failed. The name is now checked before the stream is opened. * Streams that leaked only when the wrapping constructor threw are given their own try-with-resources slot, or are closed explicitly: ConfigurationHandler, UpgradeUtils, OpenDJProvider, ProfileViewer, LDIFImportConfig, ID2Entry, Base64, BuildInformation, LogFile. Five alerts are left as they are, because closing would be wrong or the resource is already released elsewhere: InstallDS and part of Base64 wrap System.in; DirectoryServer keeps server.out for the lifetime of the process and installs it with System.setOut; TempLogFile releases its stream through writer.shutdown(); ReplicationDomain releases the import and export streams through LDIFImportConfig.close() and LDIFExportConfig.close(). java/non-sync-override (10 alerts, 8 fixed) SearchOperationBasis.abort overrode a synchronized method and mutated cancelRequest without holding the lock. RecordingInputStream.mark and reset, MissingMandatoryPropertiesException.getCause and four Swing methods are synchronized to match the contract they override. LDAPConnectionHandler.start and HTTPConnectionHandler.start are left unsynchronized on purpose. The state they touch is guarded by waitListen and the already-started check belongs to super.start(), so synchronized would add no safety, while holding the thread's own monitor across waitListen.wait() would block Thread.join() on the handler. java/thread-start-in-constructor (7 alerts, all fixed) Thread.start() is moved out of the constructor into an explicit start() method in OutputReader, NodeSearcherQueue, AsynchronousTextWriter, MultifileTextWriter, ReplicationServerDomain, MakeLDIFInputStream and DynamicGroupMemberList, so that this is no longer published to the thread before construction completes. All 19 construction sites are updated. As a side effect the rotater thread of MultifileTextWriter now starts after the rotation and retention policies have been registered, instead of possibly running before they were added.
maximthomas
left a comment
There was a problem hiding this comment.
Solid triage — the concurrency reasoning is site-specific and the two start() won't-fixes are correct for non-obvious reasons. I verified the risky parts independently: all 19 .start() sites are present, none of the 7 refactored classes has a subclass that could bypass new X via super(...), all 14 affected monitors have waiters that re-check their condition (so notifyAll is safe everywhere), and #789 merges cleanly (git merge-tree → no conflict, both changes survive).
Three things to address, all in code this PR already touches.
Synchronization on a reassigned field in InitializeTask (medium)
opendj-server-legacy/src/main/java/org/opends/server/tasks/InitializeTask.java — initState is a reassigned TaskState enum field used as a monitor, so the notifyAll() this PR converts can never reach the waiter.
// :107 runTask() locks the monitor of TaskState.RUNNING
synchronized (initState) {
while (initState == TaskState.RUNNING) {
initState.wait(1000); // :112// :147-164 updateTaskCompletionState() reassigns FIRST, then locks a DIFFERENT monitor
initState = TaskState.STOPPED_BY_ERROR;
...
initState = TaskState.COMPLETED_SUCCESSFULLY;
...
synchronized (initState) { // :162 -> monitor of COMPLETED_SUCCESSFULLY
initState.notifyAll(); // :164 waiter is parked on RUNNING; never woken
}Consequences:
- The notification is unreachable — completion is detected only by the 1-second poll.
- If the field is reassigned between the
whilecheck (:110) andinitState.wait(1000)(:112), the wait targets a monitor the thread does not hold →IllegalMonitorStateException, unchecked, so it escapesrunTask()'sInterruptedException/DirectoryExceptionhandlers. TaskState.RUNNINGis a JVM-wide enum constant, so this locks a globally shared object.
Fix: a dedicated private final Object lock plus a volatile TaskState initState.
waitListen has no waiters in LDAPConnectionHandler2 (low-medium)
opendj-server-legacy/src/main/java/org/forgerock/opendj/reactive/LDAPConnectionHandler2.java — 5 occurrences of waitListen: one declaration (:191), two synchronized, two notifyAll() (:705, :723). There is no wait() anywhere in the file and no start() override.
Two of the 25 conversions notify a monitor nobody waits on. The substantive point: unlike its LDAP sibling, the reactive handler never waits for its listener to come up, so server startup can return before the port is open. The monitor looks copy-pasted from LDAPConnectionHandler. Either wire up the wait or drop the vestigial monitor.
Unguarded wait() in HTTPConnectionHandler.start() (low)
opendj-server-legacy/src/main/java/org/opends/server/protocols/http/HTTPConnectionHandler.java:577
synchronized (waitListen) {
super.start();
try {
waitListen.wait(); // :583 no condition variable, no loopCompare opendj-server-legacy/src/main/java/org/opends/server/protocols/ldap/LDAPConnectionHandler.java:826:
while (!listenAttempted) {
waitListen.wait();
}The lost-wakeup race is covered (the monitor is held across super.start()), so this is spurious-wakeup exposure only — start() can return before the listener is confirmed. Worth giving it the same guard as its sibling while you're here.
Nits
- Duplicate copyright line:
opendj-server-legacy/src/main/java/org/opends/server/config/ConfigurationHandler.java:15-16now carries two 3A Systems lines (2025 3A Systems,LLC+2026 3A Systems, LLC.) — the only such file in the tree. Repo precedent uses ranges (SearchOperationBasis.javahas2024-2026,ReplicationDomain.javahas2025-2026), so collapse toPortions Copyright 2025-2026 3A Systems, LLC. ProfileViewercomment overstates the fix:opendj-server-legacy/src/main/java/org/opends/server/plugins/profiler/ProfileViewer.java:218-221— the comment says the stream is closed "in case the reader could not be created around it", butnew FileInputStreamandASN1.getReaderare both outside thetry, so that path is still uncovered. In practice nothing leaks either way:ASN1.getReaderdeclares no checked exceptions, andASN1InputStreamReader.close()already callsin.close()on the parent stream — so the addedfileStreamat:280is redundant. Reword the comment or use try-with-resources.- Fully-qualified call:
opendj-server-legacy/src/main/java/org/opends/server/tools/upgrade/UpgradeUtils.java:317usesorg.forgerock.util.Utils.closeSilently(...)but there is no name clash in the file (onlySchemaUtilsis imported). Add the import, or useStaticUtils.closeas the rest of the PR does. - Orphaned comment:
opendj-server-legacy/src/main/java/org/opends/server/extensions/DynamicGroupMemberList.java:129—// Assigned at the end of this constructor and started separately by start().sits ~170 lines from the assignment it describes. The field javadoc (:65) andstart()javadoc (:312) already cover it; delete. - Dead null check:
opendj-server-legacy/src/main/java/org/opends/server/loggers/AsynchronousTextWriter.java:233— removingthis.writerThread = null;from the constructor leaveswriterThread != nullpermanently true. Make the field (:45)finaland drop the check. (Unstarted threads are otherwise safe here —isAlive()is false, so neither this norMultifileTextWriter.processServerShutdownhangs ifstart()is skipped.) - PR description accuracy: the
waitListenconversions are listed under "fixes genuine missed wakeups where more than one thread can wait on the same monitor", but LDAP and HTTP have a single waiter and the reactive handler has none. The count is 6 sites, not 5. The other three claims do check out —NodeSearcherQueue(2 real consumer threads),connectPhaseLock(the wait atReplicationBroker.java:2372is insidepublish(), called concurrently by many threads), andmonitorResponse. ID2Entryrationale:reader.asInputStream()is an in-memory stream whoseclose()is a no-op, so this is not a file-descriptor fix — the real gain isInflaterInputStream.close()callinginf.end(), releasing native zlib memory eagerly instead of at GC. Worth stating, since it's a genuine win that the rule name obscures.close()cannot realistically throw here (CipherInputStream.close()swallows crypto exceptions), so masking a decoded entry isn't a practical concern.- Two-phase construction is easy to forget:
new X(...).start()compiles fine without.start(), and the failure is silent — a hungDynamicGroupMemberList/MakeLDIFInputStream, or a log that never rotates. All 19 current sites are correct, but a private constructor plusstatic X create(...)would make the mistake unrepresentable for future callers. Also,start()is not idempotent (second call throwsIllegalThreadStateException) — worth a@throwsnote.
InitializeTask synchronized on initState, a reassigned TaskState field: runTask() locked and waited on the monitor of TaskState.RUNNING while updateTaskCompletionState() reassigned the field before locking the monitor of COMPLETED_SUCCESSFULLY, so the notification never reached the waiter and completion was detected only by the one-second poll. A reassignment between the loop condition and initState.wait(1000) could also raise IllegalMonitorStateException. Use a dedicated final lock object and make the state volatile. HTTPConnectionHandler.start() waited on waitListen with no condition variable, so a spurious wakeup let startup return before the listener was confirmed. Guard the wait with a listenAttempted flag and restore the interrupt flag, matching LDAPConnectionHandler. MakeLDIFInputStream now hides its constructor behind a static newStartedInputStream() factory. Two-phase construction fails silently when start() is forgotten, and the factory also resolves the java/input-resource-leak alert that the "new ....start()" form introduced at LDIFImportConfig:207. ProfileViewer uses try-with-resources so the file stream is really closed when the ASN.1 reader cannot be created around it. Drop the dead null check on AsynchronousTextWriter.writerThread and make the field final, remove the orphaned comment in DynamicGroupMemberList, import org.forgerock.util.Utils in UpgradeUtils instead of naming it inline, and collapse the duplicated 3A Systems copyright line in ConfigurationHandler.
|
Thanks for the depth here — the independent checks on the
|
Tier 2 of the warning-severity CodeQL triage (Tier 1 is #789). Of the 73 alerts across these four rules, 66 are fixed in code; the remaining 7 are dismissed in code scanning as won't fix / false positive, each with the per-alert reason repeated below.
java/notify-instead-of-notify-all— 25 of 25Each site was checked individually: every waiter re-checks its condition in a loop, so
notifyAll()is safe everywhere. The conversion fixes genuine missed wakeups where more than one thread can wait on the same monitor:NodeSearcherQueue(3 sites) —BrowserControllercreates the queue with two consumer threads, so waking only one could leave queued work unattended.ReplicationBroker.connectPhaseLock(2 sites) — any thread that calledpublish()can be waiting there.ReplicationBroker.monitorResponse— several monitor requests can be outstanding.The remaining sites have a single waiter by construction (
TaskThread,ChangeNumberIndexer, theFileChangelogDBpurger,ServerStateFlush, …) and are converted for consistency.MessageHandler.shutdown()callednotify()immediately before an existingnotifyAll(); the redundant call is removed.The six
waitListenconversions in the LDAP / HTTP / reactive connection handlers belong to that second group rather than to the missed-wakeup list:LDAPConnectionHandlerandHTTPConnectionHandlerhave exactly one waiter (the server startup thread), andLDAPConnectionHandler2has none at all — itswaitListenmonitor was copied from its legacy sibling without the waiting half, which is tracked separately in #794.Two related defects found while checking those sites are fixed here:
InitializeTasksynchronized oninitState, a reassignedTaskStatefield.runTask()locks the monitor ofTaskState.RUNNINGand waits on it, whileupdateTaskCompletionState()reassigns the field first and then locks the monitor ofCOMPLETED_SUCCESSFULLY/STOPPED_BY_ERROR— so the notification could never reach the waiter and completion was detected only by the 1-second poll. Reassignment between the loop condition andinitState.wait(1000)could also raiseIllegalMonitorStateException, and the monitor was a JVM-wide enum constant. Replaced by a dedicatedfinal Objectlock plus avolatilestate field.HTTPConnectionHandler.start()waited onwaitListenwith no condition variable and no loop, so a spurious wakeup let startup return before the listener was confirmed. It now uses the samewhile (!listenAttempted)guard asLDAPConnectionHandler.start(), and restores the interrupt flag like its sibling does.java/input-resource-leak+java/output-resource-leak— 26 of 31GenerateGlobalAcisTableMojo(2),quicksetup/Utils.supportsOption,ConfigureWindowsService(2), and theServer/ModifyAsyncexamples.ServerController.StopReader/StartReader(4) andOutputReader(2). The reader is now closed by the same thread that drains it.ZipExtractorvalidated the.zipextension after delegating to the constructor that opens the file, so the stream leaked whenever validation failed. The name is now checked before the stream is opened.ConfigurationHandler,UpgradeUtils(2),OpenDJProvider,ProfileViewer,LDIFImportConfig(2, the GZIP wrap),ID2Entry,Base64,BuildInformation,LogFile.ID2Entrydeserves a note:reader.asInputStream()is an in-memory stream whoseclose()is a no-op, so no file descriptor is at stake. The gain is thatInflaterInputStream.close()callsInflater.end(), releasing native zlib memory eagerly instead of waiting for the finalizer.Base64is a partial fix by design: the alert covers a ternary that yields either aFileReaderor anInputStreamReader(System.in). Only the file branch is closed — closing the console input would break the tool — and that is enough to clear the alert.Five alerts are dismissed, because closing would be wrong or the resource is already released elsewhere:
InstallDS:455System.in— closing the console input would break the toolDirectoryServer:4998server.outis held for the lifetime of the process and installed viaSystem.setOutTempLogFile:84writer.shutdown()ReplicationDomain:1563,:2328LDIFExportConfig.close()/LDIFImportConfig.close()java/non-sync-override— 8 of 10SearchOperationBasis.abortoverrode asynchronizedmethod and mutatedcancelRequestwithout holding the lock — that one is a real defect.RecordingInputStream.mark/reset,MissingMandatoryPropertiesException.getCauseand four Swing methods are madesynchronizedto match the contract they override.LDAPConnectionHandler.startandHTTPConnectionHandler.startare left unsynchronized on purpose. The state they touch is guarded by thewaitListenmonitor and the already-started check belongs tosuper.start(), sosynchronizedwould add no safety — while holding the thread's own monitor acrosswaitListen.wait()would blockThread.join()on the handler. Trading real risk for no benefit, so these two are won't-fix.java/thread-start-in-constructor— 7 of 7Thread.start()is moved out of the constructor into an explicitstart()method inOutputReader,NodeSearcherQueue,AsynchronousTextWriter,MultifileTextWriter,ReplicationServerDomainandDynamicGroupMemberList, sothisis no longer published to the thread before construction completes. All 19 construction sites are updated.MakeLDIFInputStreamgoes one step further: its constructor is private and the only way to obtain an instance is the staticnewStartedInputStream()factory, which constructs and then starts. Two-phase construction is easy to get wrong —new X(...)without.start()compiles fine and fails silently at runtime — and the factory makes that mistake unrepresentable. It also keepsjava/input-resource-leakquiet: the stream escapes through areturn, whereasnew MakeLDIFInputStream(f).start()inside athis(...)delegation looked like a leaked resource to CodeQL.Side benefit: the rotater thread of
MultifileTextWriternow starts after the rotation and retention policies are registered, instead of possibly running before they were added.Verification
mvn -o compileacross all six touched modules — passes.mvn -o test-compileforopendj-server-legacy— passes..start()in the last group would compile silently, so that was checked separately: every construction site calls.start()(or the factory), and each refactored class starts its thread in exactly one place.opendj-server-legacydisables surefire and runs tests through failsafe against a real server, so the affected classes are not covered by the plain test route.Note for reviewers
This branch is based on
origin/master, soAsynchronousTextWriter.javais touched by both this PR and #789 (#789 changes the constructor, this PR removeswriterThread.start()from it). The edits are on adjacent lines and should merge cleanly, but merging #789 first is the simpler order.