-
Notifications
You must be signed in to change notification settings - Fork 651
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Provide a default CopyStrategy overload for copyItem. #2818
Merged
UncleMattHope
merged 7 commits into
apple:main
from
UncleMattHope:copyItem_default_strategy_overload
Aug 1, 2024
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
988c6ee
Provide a default CopyStrategy overload for copyItem.
UncleMattHope 86a3607
add public access to extension member
UncleMattHope b1400d6
format fixup
UncleMattHope 523ffdf
Merge branch 'main' into copyItem_default_strategy_overload
UncleMattHope 94ed19a
Fix flaky cancellation.
UncleMattHope 0715f26
Merge branch 'main' into copyItem_default_strategy_overload
UncleMattHope 613ff12
Merge branch 'main' into copyItem_default_strategy_overload
UncleMattHope File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -14,7 +14,7 @@ | |
|
||
#if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) || os(Linux) || os(Android) | ||
import NIOCore | ||
@_spi(Testing) import _NIOFileSystem | ||
@_spi(Testing) @testable import _NIOFileSystem | ||
@preconcurrency import SystemPackage | ||
import XCTest | ||
import NIOConcurrencyHelpers | ||
|
@@ -791,10 +791,49 @@ final class FileSystemTests: XCTestCase { | |
_ copyStrategy: CopyStrategy, | ||
line: UInt = #line | ||
) async throws { | ||
let path = try await self.fs.temporaryFilePath() | ||
// Whitebox testing to cover specific scenarios | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This makes the test non flaky (it reliably fails without the fix) |
||
switch copyStrategy.wrapped { | ||
case let .parallel(maxDescriptors): | ||
// The use of nested directories here allows us to rely on deterministic ordering | ||
// of the shouldCopy calls that are used to trigger the cancel. If we used files then directory | ||
// listing is not deterministic in general and that could make tests unreliable. | ||
// If maxDescriptors gets too high the resulting recursion might result in the test failing. | ||
// At that stage the tests would need some rework, but it's not viewed as likely given that | ||
// the maxDescriptors defaults should remain small. | ||
|
||
// Each dir consumes two descriptors, so this source can cover all scenarios. | ||
let depth = maxDescriptors + 1 | ||
let path = try await self.fs.temporaryFilePath() | ||
try await self.generateDeterministicDirectoryStructure( | ||
root: path, | ||
structure: TestFileStructure.makeNestedDirs(depth) { | ||
.init("dir-\(depth - $0)")! | ||
} | ||
) | ||
|
||
// This covers cancelling before/at the point we reach the limit. | ||
// If the maxDescriptors is sufficiently low we simply can't trigger | ||
// inside that phase so don't try. | ||
if maxDescriptors >= 4 { | ||
try await testCopyCancelledPartWayThrough(copyStrategy, "early_complete", path) { | ||
$0.path.lastComponent!.string == "dir-0" | ||
} | ||
} | ||
|
||
// This covers completing after we reach the steady state phase. | ||
let triggerAt = "dir-\(maxDescriptors / 2 + 1)" | ||
try await testCopyCancelledPartWayThrough(copyStrategy, "late_complete", path) { | ||
$0.path.lastComponent!.string == triggerAt | ||
} | ||
case .sequential: | ||
// nothing much to whitebox test here | ||
break | ||
} | ||
// Keep doing random ones as a sort of fuzzing, it previously highlighted some interesting cases | ||
// that are now covered in the whitebox tests above | ||
let randomPath = try await self.fs.temporaryFilePath() | ||
let _ = try await self.generateDirectoryStructure( | ||
root: path, | ||
root: randomPath, | ||
// Ensure: | ||
// - Parallelism is possible in directory scans. | ||
// - There are sub directories underneath the point we trigger cancel | ||
|
@@ -803,6 +842,22 @@ final class FileSystemTests: XCTestCase { | |
directoryProbability: 1.0, | ||
symbolicLinkProbability: 0.0 | ||
) | ||
try await testCopyCancelledPartWayThrough( | ||
copyStrategy, | ||
"randomly generated", | ||
randomPath | ||
) { source in | ||
source.path != randomPath && source.path.removingLastComponent() != randomPath | ||
} | ||
} | ||
|
||
private func testCopyCancelledPartWayThrough( | ||
_ copyStrategy: CopyStrategy, | ||
_ description: String, | ||
_ path: FilePath, | ||
triggerCancel: @escaping (DirectoryEntry) -> Bool, | ||
line: UInt = #line | ||
) async throws { | ||
|
||
let copyPath = try await self.fs.temporaryFilePath() | ||
|
||
|
@@ -814,7 +869,7 @@ final class FileSystemTests: XCTestCase { | |
throw error | ||
} shouldCopyItem: { source, destination in | ||
// Abuse shouldCopy to trigger the cancellation after getting some way in. | ||
if source.path != path && source.path.removingLastComponent() != path { | ||
if triggerCancel(source) { | ||
let shouldSleep = requestedCancel.withLockedValue { requested in | ||
if !requested { | ||
requested = true | ||
|
@@ -828,12 +883,12 @@ final class FileSystemTests: XCTestCase { | |
if shouldSleep { | ||
do { | ||
try await Task.sleep(for: .seconds(3)) | ||
XCTFail("Should have been cancelled by now!") | ||
XCTFail("\(description) Should have been cancelled by now!") | ||
} catch is CancellationError { | ||
// This is fine - we got cancelled as desired, let the rest of the in flight | ||
// logic wind down cleanly (we hope/assert) | ||
} catch let error { | ||
XCTFail("just expected a cancellation error not \(error)") | ||
XCTFail("\(description) just expected a cancellation error not \(error)") | ||
} | ||
} | ||
} | ||
|
@@ -854,13 +909,13 @@ final class FileSystemTests: XCTestCase { | |
let result = await task.result | ||
switch result { | ||
case let .success(msg): | ||
XCTFail("expected the cancellation to have happened : \(msg)") | ||
XCTFail("\(description) expected the cancellation to have happened : \(msg)") | ||
|
||
case let .failure(err): | ||
if err is CancellationError { | ||
// success | ||
} else { | ||
XCTFail("expected CancellationError not \(err)") | ||
XCTFail("\(description) expected CancellationError not \(err)") | ||
} | ||
} | ||
// We can't assert anything about the state of the copy, | ||
|
@@ -1359,6 +1414,92 @@ extension FileSystemTests { | |
XCTAssertEqual(destination1, destination2) | ||
} | ||
|
||
/// Declare a directory structure in code to make with ``generateDeterministicDirectoryStructure`` | ||
fileprivate enum TestFileStructure { | ||
case dir(_ name: FilePath.Component, _ contents: [TestFileStructure] = []) | ||
case file(_ name: FilePath.Component) | ||
// don't care about the destination yet | ||
case symbolicLink(_ name: FilePath.Component) | ||
|
||
static func makeNestedDirs( | ||
_ depth: Int, | ||
namer: (Int) -> FilePath.Component = { .init("dir-\($0)")! } | ||
) -> [TestFileStructure] { | ||
let name = namer(depth) | ||
guard depth > 0 else { | ||
return [] | ||
} | ||
return [.dir(name, makeNestedDirs(depth - 1, namer: namer))] | ||
} | ||
|
||
static func makeManyFiles( | ||
_ num: Int, | ||
namer: (Int) -> FilePath.Component = { .init("file-\($0)")! } | ||
) -> [TestFileStructure] { | ||
(0..<num).map { .file(namer($0)) } | ||
} | ||
} | ||
|
||
/// This generates a directory structure to cover specific scenarios easily | ||
private func generateDeterministicDirectoryStructure( | ||
root: FilePath, | ||
structure: [TestFileStructure] | ||
) async throws { | ||
// always make root | ||
try await self.fs.createDirectory( | ||
at: root, | ||
withIntermediateDirectories: false, | ||
permissions: nil | ||
) | ||
|
||
for item in structure { | ||
switch item { | ||
case let .dir(name, contents): | ||
try await self.generateDeterministicDirectoryStructure( | ||
root: root.appending(name), | ||
structure: contents | ||
) | ||
case let .file(name): | ||
try await self.makeTestFile(root.appending(name)) | ||
case let .symbolicLink(name): | ||
try await self.fs.createSymbolicLink( | ||
at: root.appending(name), | ||
withDestination: "nonexistent-destination" | ||
) | ||
} | ||
} | ||
} | ||
|
||
fileprivate func makeTestFile( | ||
_ path: FilePath, | ||
tryAddAttribute: String? = .none | ||
) async throws { | ||
try await self.fs.withFileHandle( | ||
forWritingAt: path, | ||
options: .newFile(replaceExisting: false) | ||
) { file in | ||
if let tryAddAttribute { | ||
let byteCount = (32...128).randomElement()! | ||
do { | ||
try await file.updateValueForAttribute( | ||
Array(repeating: 0, count: byteCount), | ||
attribute: tryAddAttribute | ||
) | ||
} catch let error as FileSystemError where error.code == .unsupported { | ||
// Extended attributes are not supported on all platforms. Ignore | ||
// errors if that's the case. | ||
() | ||
} | ||
} | ||
|
||
let byteCount = (512...1024).randomElement()! | ||
try await file.write( | ||
contentsOf: Array(repeating: 0, count: byteCount), | ||
toAbsoluteOffset: 0 | ||
) | ||
} | ||
} | ||
|
||
private func generateDirectoryStructure( | ||
root: FilePath, | ||
maxDepth: Int, | ||
|
@@ -1404,30 +1545,8 @@ extension FileSystemTests { | |
itemsCreated += 1 | ||
} else { | ||
let path = root.appending("file-\(i)-regular") | ||
try await self.fs.withFileHandle( | ||
forWritingAt: path, | ||
options: .newFile(replaceExisting: false) | ||
) { file in | ||
if Bool.random() { | ||
let byteCount = (32...128).randomElement()! | ||
do { | ||
try await file.updateValueForAttribute( | ||
Array(repeating: 0, count: byteCount), | ||
attribute: "attribute-\(i)" | ||
) | ||
} catch let error as FileSystemError where error.code == .unsupported { | ||
// Extended attributes are not supported on all platforms. Ignore | ||
// errors if that's the case. | ||
() | ||
} | ||
} | ||
|
||
let byteCount = (512...1024).randomElement()! | ||
try await file.write( | ||
contentsOf: Array(repeating: 0, count: byteCount), | ||
toAbsoluteOffset: 0 | ||
) | ||
} | ||
let attribute: String? = Bool.random() ? .some("attribute-{\(i)}") : .none | ||
try await makeTestFile(path, tryAddAttribute: attribute) | ||
itemsCreated += 1 | ||
} | ||
} | ||
|
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This fixes the flaky test, and makes cancellation reliable.