GH-11277: Fix local directory creation race in remote file GET - #11278
GH-11277: Fix local directory creation race in remote file GET#11278dlwldn30 wants to merge 1 commit into
Conversation
cppwfs
left a comment
There was a problem hiding this comment.
Thank you for your contribution!
When building the full project we see that there are test failures in FtpServerOutboundTests and SftpServerOutboundTests.
|
Reproduced both failures locally - Both assert the root cause. Attaching the Fixed by leaving the exception untouched and making the if (!localDir.exists()) {
Assert.isTrue(localDir.mkdirs() || localDir.exists(),
() -> "Failed to make local directory: " + localDir);
}This is the idiom already used by Verification:
One point for you to weigh rather than me: The PR description is updated accordingly. |
|
I think moving to the |
|
Thanks - switched to One thing worth flagging, since it changed what the adjustment had to look like. The The old assertion held only because .hasStackTraceContaining("java.lang.IllegalArgumentException: Failed to make local directory");These pass against the unfixed gateway too, so they are not written around this change. I checked by restoring only the production file to the base commit and re-running both classes - green. Verification now covers every module that builds on this class, which is what I should have done the first time:
The PR description is updated accordingly. |
cppwfs
left a comment
There was a problem hiding this comment.
Thank you again for the contribution!
| @Test | ||
| public void testInt2866InvalidLocalDirectoryExpression() { | ||
| assertThatCode(() -> this.invalidDirExpression.send(new GenericMessage<Object>("/ftpSource/ ftpSource1.txt"))) | ||
| .hasRootCauseInstanceOf(IllegalArgumentException.class) |
There was a problem hiding this comment.
Thank you for the update!
There is a need for us to test the type of exception that was thrown as well. So let's add that back to the test but checking for FileSystemException.
There was a problem hiding this comment.
Restored the type assertion as hasRootCauseInstanceOf(FileSystemException.class), alongside the existing message check. Thank you!
| assertThatExceptionOfType(Exception.class) | ||
| .isThrownBy(() -> | ||
| this.invalidDirExpression.send(new GenericMessage<Object>("sftpSource/ sftpSource1.txt"))) | ||
| .withRootCauseInstanceOf(IllegalArgumentException.class) |
…file GET Fixes: spring-projects#11277 `generateLocalDirectory()` used a non-atomic `exists()`/`mkdirs()` sequence. With a `local-directory-expression` the eager `setupLocalDirectory()` in `afterPropertiesSet()` is skipped, since it only runs for a `ValueExpression`, so the directory is created per message on the calling thread. Concurrent messages resolving to the same, not-yet-existing local directory raced on `File.mkdirs()`: the losing threads received `false` and failed with `IllegalArgumentException: Failed to make local directory: [...]` although the directory existed at that point. * Use the idempotent and concurrency-safe `Files.createDirectories()` instead, retaining the exception type and message and propagating a genuine creation failure as cause This mirrors the fix applied to `FileWritingMessageHandler` in spring-projectsGH-11254. Attaching the `IOException` as cause moves the root cause of the reported `IllegalArgumentException` from itself to the `FileSystemException` raised by the failed creation. `FtpServerOutboundTests` and `SftpServerOutboundTests` pinned the old position via `hasRootCauseInstanceOf(IllegalArgumentException)`, which held only because the previous `Assert.isTrue()` left the chain empty. * Assert the `IllegalArgumentException` and its message where they now occur in the chain, and keep a root cause type assertion for the `FileSystemException` that the failed creation contributes Signed-off-by: Jiwoo Lee <dlwldn30@naver.com>
Fixes: #11277
The defect
AbstractRemoteFileOutboundGateway.generateLocalDirectory()creates the local directory with a non-atomicexists()/mkdirs()sequence, per message, on the calling thread:The eager creation in
afterPropertiesSet()does not cover this, because it is restricted to aValueExpression:So with a
local-directory-expressionthe directory is never pre-created and creation relies entirely on the per-message path. Concurrent messages resolving to the same, not-yet-existing directory all pass!localDir.exists(), only onemkdirs()wins, and the losing threads getfalseand fail:The directory exists at that point and the transfer could have proceeded.
The code is identical in
v6.0.0,v6.3.0,v7.0.0andmain.The change
Use
Files.createDirectories(), which is idempotent and does not report an already-existing directory as a failure, and propagate a genuine creation failure as the cause:This mirrors
FileWritingMessageHandler, which has used the same call for the same class of problem since #11254.The two adjusted tests
Attaching the
IOExceptionas cause moves the root cause of the reportedIllegalArgumentExceptionfrom itself to aFileSystemException:FtpServerOutboundTests.testInt2866InvalidLocalDirectoryExpressionand itsSftpServerOutboundTestscounterpart pinned that position withhasRootCauseInstanceOf(IllegalArgumentException.class). That held only becauseAssert.isTrue()left the chain empty, so the exception happened to be its own root - it was not an intentional assertion about the root.They now assert the same type and message where they occur in the chain rather than at its root:
hasCauseInstanceOf()would not work either - theIllegalArgumentExceptionsits two levels down, under theMessagingExceptionfrom the session template.These assertions pass against the unfixed gateway as well, so they assert the contract rather than encoding this change. Verified by restoring only the production file to the base commit and re-running both classes - green.
Tests
RemoteFileOutboundGatewayTests.testGetConcurrentCreateSameLocalDirectory- 8 threads, 50 rounds, a fresh local directory per round, one distinct remote file per thread so only the destination directory is shared. It takes an isolated@TempDirparameter rather than the shared static one, so it cannot perturbtestMputRecursive.The previous revision was verified only against
:spring-integration-file:check, which is how the-ftpand-sftpfailures were missed. All three modules that build on this class are now in scope::spring-integration-file:check:spring-integration-ftp:test:spring-integration-sftp:testcheckstyleMainandcheckstyleTestpass for all three.Revert check: restoring only the production file with
git checkout <base> -- <file>(notgit stash, which would have been a no-op after the commit) makes exactly the new test fail - 36 tests completed, 1 failed.Scope
setupLocalDirectory()has the same shape (exists()thenmkdirs(), at line 612) but runs once fromafterPropertiesSet()on a single thread, so it cannot race. I left it alone to keep the diff minimal - happy to align it for consistency if you prefer.AbstractInboundFileSynchronizingMessageSourceandFileReadingMessageSourcecarry the same pattern. The former I have not traced to a concurrent caller; the latter is guarded byrunning.getAndSet(true)instart(). Both look safe today, but they are the same class of code and might be worth a sweep separately.No open PR touches
AbstractRemoteFileOutboundGateway.java.Contributed on behalf of Goatshave.