Conversation
…import task can end on ImportTask closed its LDIFImportConfig after the outer try/finally, so only a fully successful import reached it: a backend that could not be disabled, a lock that could not be taken, an importLDIF() that threw, a lock that could not be released and a backend that could not be re-enabled all returned above it. A backend closes the config together with its LDIF reader, so what this left open were the reject and skip files of an import that failed before that reader existed - the task opens them before the backend is touched - for as long as the completed task is retained. The close now opens the finally, ahead of the re-enable and the listener notification, whichever way the import ended; closing a config the backend already closed is a no-op. Fixes OpenIdentityPlatform#1026.
d23c23d to
5b4444f
Compare
|
@maximthomas rebased onto Nothing in the change itself moved - |
maximthomas
left a comment
There was a problem hiding this comment.
praise: The close now sits where every road of runTask unwinds, and the test really pins it.
importConfig.close()is the first statement of the outerfinally(ImportTask.java:726), ahead of the re-enable andnotifyImportEnded, so the disable-fail (:650), lock-fail (:661,:669) andimportLDIF()(:678,:692) returns all reach it; notryof its own is right —StaticUtils.closeswallows theIOException, and a second close of the reader-owned config is idempotent on every streamgetReader()stacks.testFailedImportClosesItsRejectAndSkipFilesis measured red withImportTask.javaat the base and the test at the head — 15 ran, 1 failed,The reject writer is still open after the import ended— and green on every ubuntu cell at the head.TestTaskListener.lastImportEndConfigreads the config through the listener road the server already offers instead of reaching into the task.
issue (blocking): The skip-file open failure still returns with the reject writer open — above the finally the close was moved into.
opendj-server-legacy/src/main/java/org/opends/server/tasks/ImportTask.java:617-621, :600, :615
writeRejectedEntries at :600 opens the reject file (LDIFImportConfig.newBufferedWriter:373-375, a FileWriter — truncated under ds-task-import-overwrite-rejects: TRUE). When writeSkippedEntries at :615 then fails — an unwritable path, a directory; initializeTask only stores the attribute as a string — the catch at :617 returns STOPPED_BY_ERROR at :620, above notifyImportBeginning (:628) and above the try at :636, so the close at :726 never runs and nothing else closes the config (interruptTask only calls cancel(); no finalizer, no task-level cleanup). The block is unchanged by this PR, but it is the one early return left that leaks what the title says is closed on every path, and its mirror (reject open failing at :604) leaks nothing because nothing is open yet.
catch (Exception e)
{
logger.error(ERR_LDIFIMPORT_CANNOT_OPEN_SKIP_FILE, skipFile, getExceptionMessage(e));
// The reject file is already open; no finally below this return closes it.
importConfig.close();
return TaskState.STOPPED_BY_ERROR;
}Pin: the new test's observable cannot see this road — the return happens before notifyImportBeginning, so processImportEnd never fires — a pin needs another handle on the config; the one-line close is the required part.
suggestion (non-blocking): Only the importLDIF()-throws road is pinned; the disable-fail and lock-fail roads the description names are closed by construction.
opendj-server-legacy/src/test/java/org/opends/server/tasks/TestImportAndExport.java:461-494, :397-425
The directory-as-LDIF injection fails inside backend.importLDIF (ImportTask.java:675), i.e. inside the innermost try (:673) with backendDisabled == true. A close moved into the lock-release finally (:699-700), guarded with if (backendDisabled), or put back at its old place with an extra importConfig.close() in the catch (DirectoryException) arm at :678, still closes the writers on that road and stays green across the class, while the disable-fail return at :650 leaks again. testImportEndsWhenTheBackendCannotBeDisabled already has the fixture; it sets no reject or skip file and asserts only the begin/end counts.
// in testImportEndsWhenTheBackendCannotBeDisabled, with a temp skip file as in the new case
Entry taskEntry = TestCaseUtils.makeEntry(
"dn: ds-task-id=" + UUID.randomUUID() + ",cn=Scheduled Tasks,cn=Tasks",
"objectclass: top",
"objectclass: ds-task",
"objectclass: ds-task-import",
"ds-task-class-name: org.opends.server.tasks.ImportTask",
"ds-task-import-backend-id: " + backendID,
"ds-task-import-ldif-file: " + ldifFile.getPath(),
"ds-task-import-reject-file: " + rejectFile.getPath(),
"ds-task-import-skip-file: " + skipFile.getPath(),
"ds-task-import-overwrite-rejects: TRUE");
TestTaskListener.lastImportEndConfig.set(null);
testTask(taskEntry, TaskState.STOPPED_BY_ERROR, 60);
assertEquals(TestTaskListener.importBeginCount.get(), importBeginCount + 1);
assertEquals(TestTaskListener.importEndCount.get(), importEndCount + 1);
LDIFImportConfig importConfig = TestTaskListener.lastImportEndConfig.get();
assertNotNull(importConfig, "The import end was not notified");
assertClosed(importConfig.getRejectWriter(), "reject");
assertClosed(importConfig.getSkipWriter(), "skip");Pin: the writers open at :600/:615 before the disable at :640, and notifyImportEnded (:749) runs on that road, so under any of the three mutants the disable-fail road hands open writers to the listener and assertClosed goes red. The lock-fail road has no injection in the tree and stays by construction.
…is the one which cannot be opened The close this PR moved into the finally of runTask() is reached by every return below it, but the skip file is opened above it: writeRejectedEntries() opens the reject file the moment it is called, and when writeSkippedEntries() then fails - on a path the task never checks, it only stores the attribute as a string - the return goes above notifyImportBeginning() and above the try whose finally closes the config, leaving the reject writer open for as long as the completed task is retained. The catch closes the config itself. The two files are not opened inside that try instead, which would need no new close: a listener told an import began puts back what it took offline when it is told it ended - a replication domain reloads and rewinds its state - and an import which never reached a backend has nothing for it to put back. testImportWhichCannotOpenItsSkipFileClosesItsRejectFile pins it. That return is above the notification the other cases read the config through, so the config is read from the task the scheduler kept. testImportEndsWhenTheBackendCannotBeDisabled now carries a reject and a skip file too: it is the road which ends furthest from the backend, so a close which rides on the backend having been disabled, or on the import having been launched, passes the other cases and fails this one.
|
@maximthomas both taken, round 2 is issue (blocking) - the skip-file open failure leaks the reject writerConfirmed and fixed.
I did not take the other road out of it - opening the two files below The mirror return at Pin. suggestion (non-blocking) - only the importLDIF road was pinnedTaken as written. Measured,
The lock-fail road still has no injection in the tree and stays by construction. Noticed while checking the mirror returnA template import - That is inside RunsRound 2, head of the branch:
|
Fixes #1026.
The leak
ImportTaskclosed itsLDIFImportConfigafter the outertry/finally, so only a fully successful import reached it: a backend that could not be disabled, a lock that could not be taken, animportLDIF()that threw, a lock that could not be released and a backend that could not be re-enabled all returned above it.What that actually leaves open is narrower than the config as a whole. Every backend closes the config together with its LDIF reader (
LDIFReader.close()→importConfig.close(), inside the backends' try-with-resources), so an import that got as far as constructing the reader is closed - and its rejects file flushed - whichever way it ends. What stayed open were the reject and skip files of an import that failed before that reader existed: the task opens them before the backend is touched, and a failed disable, a failed lock, or animportLDIF()that dies opening the LDIF (a missing file, a directory, a backend still online) never reached a reader. They stayed open for as long as the completed task is retained.The change
importConfig.close()now opens thefinally, ahead of the re-enable and the listener notification, so every return below it reaches the close. Notryof its own:StaticUtils.close()swallows theIOException, and closing a config the backend already closed is a no-op.One return is above that
finallyand is closed where it stands. The reject file is opened before the skip file, and the skip file is opened on a path the task never checks -initializeTaskonly stores the attribute as a string - so an import whose skip file cannot be opened returnsSTOPPED_BY_ERRORwith the reject writer already open, abovenotifyImportBeginningand above thetry. That catch closes the config itself.The two files are not moved inside the
tryinstead, which would have needed no second close: a listener told an import began puts back what it took offline when it is told it ended - a replication domain reloads and rewinds its state - and an import which never reached a backend has nothing for a listener to put back. The mirror return, the reject file itself failing to open, leaks nothingclose()would close: the LDIF reader is lazy, so at that point nothing is open.Tests
In
TestImportAndExport, three roads, each measured against a mutant ofImportTask:testFailedImportClosesItsRejectAndSkipFiles- an import whose LDIF path is a directory, accepted by the task validation and failing insideimportLDIF()before the backend creates its reader, with a reject and a skip file set. After the task ends, a write to either writer must be refused asStream closed.testImportEndsWhenTheBackendCannotBeDisablednow carries a reject and a skip file too. It is the road which ends furthest from the backend, so a close which rides on the backend having been disabled, or on the import having been launched, passes the first case and fails this one.testImportWhichCannotOpenItsSkipFileClosesItsRejectFile- a valid LDIF with a directory as the skip file. That return is above the notification the other two read the config through, so the config is read from the task the scheduler kept, and the case also asserts the skip writer was never assigned.TestTaskListenerkeeps the config it was handed atprocessImportEndfor the first two.Each of the three mutants below is one failure out of sixteen, on exactly one case, with the rest of the class green:
testImportWhichCannotOpenItsSkipFileClosesItsRejectFilefinallyunderif (backendDisabled)testImportEndsWhenTheBackendCannotBeDisabledtry/finally, plus one incatch (DirectoryException)testImportEndsWhenTheBackendCannotBeDisabledRuns
TestImportAndExport16/16,LDIFBackendTestCase22/22,PrivilegeTestCase185/185,ReSyncTest2/2.Out of scope, noticed on the way
ds-task-import-overwrite-rejects: TRUEthe reject file is opened - and truncated - before the backend is disabled, so an import that fails on the disable has already emptied the previous run's rejects. Pre-existing, unrelated to the close.new LDIFImportConfig(tf)→MakeLDIFInputStream.newStartedInputStream()), andLDIFImportConfig.close()closes only the reader and the two writers - never the input stream it built for itself. On every road fixed here the reader isnull, becausegetReader()was never called, so a failed template import still leaves a live, non-daemonMakeLDIF Input Stream Threadbehind. That is insideLDIFImportConfig.close()rather than in this task: A MakeLDIF import which fails before its reader leaves the generator thread running #1078.