Skip to content

Commit fb190b1

Browse files
committed
FoundationEssentials: try to make the file atomic behaviour more robust
We have observed `ERROR_ACCESS_DENIED` in CI on `SetRenameInformationFile`. Try to make the path more robust by first performing a kernel based rename with POSIX semantics. This requires Windows 10 1809+. If that is unsuccessful, verify that attributes are not to blame. A failure may still occur if the file is on a different volume. In such a case, fallback to the `MoveFileExW` operation to perform a copy + delete operation. Hopefully this should make the implementation more robust to failures.
1 parent 081798c commit fb190b1

File tree

3 files changed

+100
-25
lines changed

3 files changed

+100
-25
lines changed

Sources/FoundationEssentials/Data/Data+Writing.swift

Lines changed: 69 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -387,44 +387,88 @@ private func writeToFileAux(path inPath: PathOrURL, buffer: UnsafeRawBufferPoint
387387
// TODO: Somehow avoid copying back and forth to a String to hold the path
388388

389389
#if os(Windows)
390-
try inPath.path.withNTPathRepresentation { pwszPath in
391-
var (fd, auxPath, temporaryDirectoryPath) = try createProtectedTemporaryFile(at: inPath.path, inPath: inPath, options: options, variant: "Folder")
390+
var (fd, auxPath, temporaryDirectoryPath) = try createProtectedTemporaryFile(at: inPath.path, inPath: inPath, options: options, variant: "Folder")
392391

393-
// Cleanup temporary directory
394-
defer { cleanupTemporaryDirectory(at: temporaryDirectoryPath) }
392+
// Cleanup temporary directory
393+
defer { cleanupTemporaryDirectory(at: temporaryDirectoryPath) }
395394

396-
guard fd >= 0 else {
395+
guard fd >= 0 else {
396+
throw CocoaError.errorWithFilePath(inPath, errno: errno, reading: false)
397+
}
398+
399+
defer { if fd >= 0 { _close(fd) } }
400+
401+
let callback = (reportProgress && Progress.current() != nil) ? Progress(totalUnitCount: Int64(buffer.count)) : nil
402+
403+
do {
404+
try write(buffer: buffer, toFileDescriptor: fd, path: inPath, parentProgress: callback)
405+
} catch {
406+
try auxPath.withNTPathRepresentation { pwszAuxPath in
407+
_ = DeleteFileW(pwszAuxPath)
408+
}
409+
410+
if callback?.isCancelled ?? false {
411+
throw CocoaError(.userCancelled)
412+
} else {
397413
throw CocoaError.errorWithFilePath(inPath, errno: errno, reading: false)
398414
}
415+
}
399416

400-
defer { if fd >= 0 { _close(fd) } }
417+
writeExtendedAttributes(fd: fd, attributes: attributes)
401418

402-
let callback = (reportProgress && Progress.current() != nil) ? Progress(totalUnitCount: Int64(buffer.count)) : nil
419+
_close(fd)
420+
fd = -1
403421

404-
do {
405-
try write(buffer: buffer, toFileDescriptor: fd, path: inPath, parentProgress: callback)
406-
} catch {
407-
try auxPath.withNTPathRepresentation { pwszAuxPath in
408-
_ = DeleteFileW(pwszAuxPath)
409-
}
422+
try auxPath.withNTPathRepresentation { pwszAuxiliaryPath in
423+
defer { _ = DeleteFileW(pwszAuxiliaryPath) }
410424

411-
if callback?.isCancelled ?? false {
412-
throw CocoaError(.userCancelled)
413-
} else {
414-
throw CocoaError.errorWithFilePath(inPath, errno: errno, reading: false)
425+
var hFile = CreateFileW(pwszAuxiliaryPath, DELETE,
426+
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
427+
nil, OPEN_EXISTING,
428+
FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT,
429+
nil)
430+
if hFile == INVALID_HANDLE_VALUE {
431+
throw CocoaError.errorWithFilePath(inPath, win32: GetLastError(), reading: false)
432+
}
433+
434+
defer {
435+
switch hFile {
436+
case INVALID_HANDLE_VALUE:
437+
break
438+
default:
439+
_ = CloseHandle(hFile)
415440
}
416441
}
417442

418-
writeExtendedAttributes(fd: fd, attributes: attributes)
443+
try inPath.path.withNTPathRepresentation { pwszPath in
444+
let cchLength = wcslen(pwszPath)
445+
let cbSize = cchLength * MemoryLayout<WCHAR>.size
446+
let dwSize = DWORD(MemoryLayout<FILE_RENAME_INFO>.size + cbSize + MemoryLayout<WCHAR>.size)
447+
try withUnsafeTemporaryAllocation(byteCount: Int(dwSize),
448+
alignment: MemoryLayout<FILE_RENAME_INFO>.alignment) { pBuffer in
449+
var pInfo = pBuffer.baseAddress?.bindMemory(to: FILE_RENAME_INFO.self, capacity: 1)
450+
pInfo?.pointee.Flags = FILE_RENAME_FLAG_POSIX_SEMANTICS | FILE_RENAME_FLAG_REPLACE_IF_EXISTS
451+
pInfo?.pointee.RootDirectory = nil
452+
pInfo?.pointee.FileNameLength = DWORD(cbSize)
453+
pBuffer.baseAddress?.advanced(by: MemoryLayout<FILE_RENAME_INFO>.offset(of: \.FileName)!)
454+
.withMemoryRebound(to: WCHAR.self, capacity: cchLength + 1) {
455+
wcscpy_s($0, cchLength + 1, pwszPath)
456+
}
419457

420-
_close(fd)
421-
fd = -1
458+
if !SetFileInformationByHandle(hFile, FileRenameInfoEx, pInfo, dwSize) {
459+
let dwError = GetLastError()
460+
guard dwError == ERROR_NOT_SAME_DEVICE else {
461+
throw CocoaError.errorWithFilePath(inPath, win32: dwError, reading: false)
462+
}
422463

423-
try auxPath.withNTPathRepresentation { pwszAuxiliaryPath in
424-
guard MoveFileExW(pwszAuxiliaryPath, pwszPath, MOVEFILE_COPY_ALLOWED | MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) else {
425-
let dwError = GetLastError()
426-
_ = DeleteFileW(pwszAuxiliaryPath)
427-
throw CocoaError.errorWithFilePath(inPath, win32: dwError, reading: false)
464+
_ = CloseHandle(hFile)
465+
hFile = INVALID_HANDLE_VALUE
466+
467+
// The move is across volumes.
468+
guard MoveFileExW(pwszAuxiliaryPath, pwszPath, MOVEFILE_COPY_ALLOWED | MOVEFILE_REPLACE_EXISTING) else {
469+
throw CocoaError.errorWithFilePath(inPath, win32: GetLastError(), reading: false)
470+
}
471+
}
428472
}
429473
}
430474
}

Sources/FoundationEssentials/WinSDK+Extensions.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ package var CREATE_NEW: DWORD {
4141
DWORD(WinSDK.CREATE_NEW)
4242
}
4343

44+
package var DELETE: DWORD {
45+
DWORD(WinSDK.DELETE)
46+
}
47+
4448
package var ERROR_ACCESS_DENIED: DWORD {
4549
DWORD(WinSDK.ERROR_ACCESS_DENIED)
4650
}
@@ -133,6 +137,7 @@ package var FILE_ATTRIBUTE_READONLY: DWORD {
133137
DWORD(WinSDK.FILE_ATTRIBUTE_READONLY)
134138
}
135139

140+
136141
package var FILE_ATTRIBUTE_REPARSE_POINT: DWORD {
137142
DWORD(WinSDK.FILE_ATTRIBUTE_REPARSE_POINT)
138143
}
@@ -153,6 +158,14 @@ package var FILE_NAME_NORMALIZED: DWORD {
153158
DWORD(WinSDK.FILE_NAME_NORMALIZED)
154159
}
155160

161+
package var FILE_RENAME_FLAG_POSIX_SEMANTICS: DWORD {
162+
DWORD(WinSDK.FILE_RENAME_FLAG_POSIX_SEMANTICS)
163+
}
164+
165+
package var FILE_RENAME_FLAG_REPLACE_IF_EXISTS: DWORD {
166+
DWORD(WinSDK.FILE_RENAME_FLAG_REPLACE_IF_EXISTS)
167+
}
168+
156169
package var FILE_SHARE_DELETE: DWORD {
157170
DWORD(WinSDK.FILE_SHARE_DELETE)
158171
}

Tests/FoundationEssentialsTests/DataIOTests.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,24 @@ private final class DataIOTests {
193193
let maps = try String(contentsOfFile: "/proc/self/maps", encoding: .utf8)
194194
#expect(!maps.isEmpty)
195195
}
196+
197+
@Test
198+
func atomicWrite() async throws {
199+
let data = generateTestData()
200+
201+
await withThrowingTaskGroup(of: Void.self) { group in
202+
for _ in 0 ..< 8 {
203+
group.addTask { [url] in
204+
#expect(throws: Never.self) {
205+
try data.write(to: url, options: [.atomic])
206+
}
207+
}
208+
}
209+
}
210+
211+
let readData = try Data(contentsOf: url, options: [])
212+
#expect(readData == data)
213+
}
196214
}
197215

198216
extension LargeDataTests {

0 commit comments

Comments
 (0)