Skip to content

Commit 9428f62

Browse files
authored
Add a fallback path if renameat2 fails (#2733)
Motivation: 'renameat2' can fail with EINVAL if the RENAME_NOREPLACE flag isn't supported. However, not all kernel versions support this flag which can result in an error when creating a file 'transactionally'. Using 'renameat2' only happens on Linux as a fallback when O_TMPFILE isn't available. Modifications: - On Linux, fallback to a combination of 'stat' and 'rename' if 'renameat2' fails with EINVAL. - Add a couple of tests Result: Files can still be created exclusively
1 parent e2aef20 commit 9428f62

File tree

2 files changed

+124
-16
lines changed

2 files changed

+124
-16
lines changed

Sources/NIOFileSystem/Internal/SystemFileHandle.swift

Lines changed: 75 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -650,7 +650,7 @@ extension SystemFileHandle.SendableView {
650650
}
651651

652652
@_spi(Testing)
653-
public func _close(materialize: Bool) -> Result<Void, FileSystemError> {
653+
public func _close(materialize: Bool, failRenameat2WithEINVAL: Bool = false) -> Result<Void, FileSystemError> {
654654
let descriptor: FileDescriptor? = self.lifecycle.withLockedValue { lifecycle in
655655
switch lifecycle {
656656
case let .open(descriptor):
@@ -666,7 +666,7 @@ extension SystemFileHandle.SendableView {
666666
}
667667

668668
// Materialize then close.
669-
let materializeResult = self._materialize(materialize, descriptor: descriptor)
669+
let materializeResult = self._materialize(materialize, descriptor: descriptor, failRenameat2WithEINVAL: failRenameat2WithEINVAL)
670670

671671
return Result {
672672
try descriptor.close()
@@ -778,7 +778,8 @@ extension SystemFileHandle.SendableView {
778778

779779
func _materialize(
780780
_ materialize: Bool,
781-
descriptor: FileDescriptor
781+
descriptor: FileDescriptor,
782+
failRenameat2WithEINVAL: Bool
782783
) -> Result<Void, FileSystemError> {
783784
guard let materialization = self.materialization else { return .success(()) }
784785

@@ -803,7 +804,7 @@ extension SystemFileHandle.SendableView {
803804

804805
case .rename:
805806
if materialize {
806-
let renameResult: Result<Void, Errno>
807+
var renameResult: Result<Void, Errno>
807808
let renameFunction: String
808809
#if canImport(Darwin)
809810
renameFunction = "renamex_np"
@@ -817,19 +818,76 @@ extension SystemFileHandle.SendableView {
817818
// ignored. However, they must still be provided to 'rename' in order to pass
818819
// flags.
819820
renameFunction = "renameat2"
820-
renameResult = Syscall.rename(
821-
from: createdPath,
822-
relativeTo: .currentWorkingDirectory,
823-
to: desiredPath,
824-
relativeTo: .currentWorkingDirectory,
825-
flags: materialization.exclusive ? [.exclusive] : []
826-
)
821+
if materialization.exclusive, failRenameat2WithEINVAL {
822+
renameResult = .failure(.invalidArgument)
823+
} else {
824+
renameResult = Syscall.rename(
825+
from: createdPath,
826+
relativeTo: .currentWorkingDirectory,
827+
to: desiredPath,
828+
relativeTo: .currentWorkingDirectory,
829+
flags: materialization.exclusive ? [.exclusive] : []
830+
)
831+
}
827832
#endif
828833

829-
// A file exists at the desired path and the user specified exclusive creation,
830-
// clear up by removing the file we did create.
831-
if materialization.exclusive, case .failure(.fileExists) = renameResult {
832-
_ = Libc.remove(createdPath)
834+
if materialization.exclusive {
835+
switch renameResult {
836+
case .failure(.fileExists):
837+
// A file exists at the desired path and the user specified exclusive
838+
// creation, clear up by removing the file that we did create.
839+
_ = Libc.remove(createdPath)
840+
841+
case .failure(.invalidArgument):
842+
// If 'renameat2' failed on Linux with EINVAL then in all likelihood the
843+
// 'RENAME_NOREPLACE' option isn't supported. As we're doing an exclusive
844+
// create, check the desired path doesn't exist then do a regular rename.
845+
#if canImport(Glibc) || canImport(Musl)
846+
switch Syscall.stat(path: desiredPath) {
847+
case .failure(.noSuchFileOrDirectory):
848+
// File doesn't exist, do a 'regular' rename.
849+
renameResult = Syscall.rename(from: createdPath, to: desiredPath)
850+
851+
case .success:
852+
// File exists so exclusive create isn't possible. Remove the file
853+
// we did create then throw.
854+
_ = Libc.remove(createdPath)
855+
let error = FileSystemError(
856+
code: .fileAlreadyExists,
857+
message: """
858+
Couldn't open '\(desiredPath)', it already exists and the \
859+
file was opened with the 'existingFile' option set to 'none'.
860+
""",
861+
cause: nil,
862+
location: .here()
863+
)
864+
return .failure(error)
865+
866+
case .failure:
867+
// Failed to stat the desired file for reasons unknown. Remove the file
868+
// we did create then throw.
869+
_ = Libc.remove(createdPath)
870+
let error = FileSystemError(
871+
code: .unknown,
872+
message: "Couldn't open '\(desiredPath)'.",
873+
cause: FileSystemError.rename(
874+
"renameat2",
875+
errno: .invalidArgument,
876+
oldName: createdPath,
877+
newName: desiredPath,
878+
location: .here()
879+
),
880+
location: .here()
881+
)
882+
return .failure(error)
883+
}
884+
#else
885+
() // Not Linux, use the normal error flow.
886+
#endif
887+
888+
case .success, .failure:
889+
()
890+
}
833891
}
834892

835893
result = renameResult.mapError { errno in
@@ -1238,7 +1296,8 @@ extension SystemFileHandle {
12381296
}
12391297
}
12401298

1241-
static func syncOpenWithMaterialization(
1299+
@_spi(Testing)
1300+
public static func syncOpenWithMaterialization(
12421301
atPath path: FilePath,
12431302
mode: FileDescriptor.AccessMode,
12441303
options originalOptions: FileDescriptor.OpenOptions,

Tests/NIOFileSystemIntegrationTests/FileHandleTests.swift

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -995,6 +995,55 @@ final class FileHandleTests: XCTestCase {
995995
}
996996
}
997997

998+
func testOpenExclusiveCreateForFileWhichExistsWithoutOTMPFILE() async throws {
999+
// Takes the path where 'O_TMPFILE' doesn't exist, so materializing the file is done via
1000+
// creating a temporary file and then renaming it using 'renamex_np'/'renameat2' (Darwin/Linux).
1001+
let temporaryDirectory = try await FileSystem.shared.temporaryDirectory
1002+
let path = temporaryDirectory.appending(Self.temporaryFileName().components)
1003+
let handle = try SystemFileHandle.syncOpenWithMaterialization(
1004+
atPath: path,
1005+
mode: .writeOnly,
1006+
options: [.exclusiveCreate, .create],
1007+
permissions: .ownerReadWrite,
1008+
threadPool: .singleton,
1009+
useTemporaryFileIfPossible: false
1010+
).get()
1011+
1012+
// Closing shouldn't throw and the file should now be visible.
1013+
try await handle.close()
1014+
let info = try await FileSystem.shared.info(forFileAt: path)
1015+
XCTAssertNotNil(info)
1016+
}
1017+
1018+
func testOpenExclusiveCreateForFileWhichExistsWithoutOTMPFILEOrRenameat2() async throws {
1019+
// Takes the path where 'O_TMPFILE' doesn't exist, so materializing the file is done via
1020+
// creating a temporary file and then renaming it using 'renameat2' and then takes a further
1021+
// fallback path where 'renameat2' returns EINVAL so the 'rename' is used in combination
1022+
// with 'stat'. This path is only reachable on Linux.
1023+
#if canImport(Glibc) || canImport(Musl)
1024+
let temporaryDirectory = try await FileSystem.shared.temporaryDirectory
1025+
let path = temporaryDirectory.appending(Self.temporaryFileName().components)
1026+
let handle = try SystemFileHandle.syncOpenWithMaterialization(
1027+
atPath: path,
1028+
mode: .writeOnly,
1029+
options: [.exclusiveCreate, .create],
1030+
permissions: .ownerReadWrite,
1031+
threadPool: .singleton,
1032+
useTemporaryFileIfPossible: false
1033+
).get()
1034+
1035+
// Close, but take the path where 'renameat2' fails with EINVAL. This shouldn't throw and
1036+
// the file should be available.
1037+
let result = handle.sendableView._close(materialize: true, failRenameat2WithEINVAL: true)
1038+
try result.get()
1039+
1040+
let info = try await FileSystem.shared.info(forFileAt: path)
1041+
XCTAssertNotNil(info)
1042+
#else
1043+
throw XCTSkip("This test requires 'renameat2' which isn't supported on this platform")
1044+
#endif
1045+
}
1046+
9981047
func testOpenExclusiveCreateForFileWhichDoesNotExist() async throws {
9991048
try await self.withTestDataDirectory { dir in
10001049
let path = Self.temporaryFileName()

0 commit comments

Comments
 (0)