Skip to content

Commit dfa0d24

Browse files
authored
Refactor test content discovery to produce a sequence. (#914)
This PR does away with `enumerateTestContent {}` and replaces it with `discover()`, a function that returns a sequence of `TestContentRecord` instances that the caller can then inspect. This simplifies the logic in callers by letting them use sequence operations/monads like `map()`, and simplifies the logic in the implementation by doing away with intermediate tuples and allowing it to just map section bounds to sequences of `TestContentRecord` values. Evaluation of the accessor functions for test content records is now lazier, allowing us to potentially leverage the `context` field of a test content record to avoid calling accessors for records we know won't match due to the content of that field. (This functionality isn't currently needed, but could be useful for future test content types.) ### Checklist: - [x] Code and documentation should follow the style of the [Style Guide](https://github.com/apple/swift-testing/blob/main/Documentation/StyleGuide.md). - [x] If public symbols are renamed or modified, DocC references should be updated.
1 parent 46afc15 commit dfa0d24

File tree

5 files changed

+135
-153
lines changed

5 files changed

+135
-153
lines changed

Sources/Testing/Discovery+Platform.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ struct SectionBounds: Sendable {
3535
///
3636
/// - Returns: A sequence of structures describing the bounds of metadata
3737
/// sections of the given kind found in the current process.
38-
static func all(_ kind: Kind) -> some RandomAccessCollection<SectionBounds> {
38+
static func all(_ kind: Kind) -> some Sequence<SectionBounds> {
3939
_sectionBounds(kind)
4040
}
4141
}
@@ -237,7 +237,7 @@ private func _sectionBounds(_ kind: SectionBounds.Kind) -> [SectionBounds] {
237237
case .typeMetadata:
238238
".sw5tymd"
239239
}
240-
return HMODULE.all.compactMap { _findSection(named: sectionName, in: $0) }
240+
return HMODULE.all.lazy.compactMap { _findSection(named: sectionName, in: $0) }
241241
}
242242
#else
243243
/// The fallback implementation of ``SectionBounds/all(_:)`` for platforms that

Sources/Testing/Discovery.swift

Lines changed: 71 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -29,21 +29,6 @@ public typealias __TestContentRecord = (
2929
reserved2: UInt
3030
)
3131

32-
/// Resign any pointers in a test content record.
33-
///
34-
/// - Parameters:
35-
/// - record: The test content record to resign.
36-
///
37-
/// - Returns: A copy of `record` with its pointers resigned.
38-
///
39-
/// On platforms/architectures without pointer authentication, this function has
40-
/// no effect.
41-
private func _resign(_ record: __TestContentRecord) -> __TestContentRecord {
42-
var record = record
43-
record.accessor = record.accessor.map(swt_resign)
44-
return record
45-
}
46-
4732
// MARK: -
4833

4934
/// A protocol describing a type that can be stored as test content at compile
@@ -79,42 +64,68 @@ protocol TestContent: ~Copyable {
7964
associatedtype TestContentAccessorHint: Sendable = Never
8065
}
8166

82-
extension TestContent where Self: ~Copyable {
83-
/// Enumerate all test content records found in the given test content section
84-
/// in the current process that match this ``TestContent`` type.
67+
// MARK: - Individual test content records
68+
69+
/// A type describing a test content record of a particular (known) type.
70+
///
71+
/// Instances of this type can be created by calling
72+
/// ``TestContent/allTestContentRecords()`` on a type that conforms to
73+
/// ``TestContent``.
74+
///
75+
/// This type is not part of the public interface of the testing library. In the
76+
/// future, we could make it public if we want to support runtime discovery of
77+
/// test content by second- or third-party code.
78+
struct TestContentRecord<T>: Sendable where T: ~Copyable {
79+
/// The base address of the image containing this instance, if known.
8580
///
86-
/// - Parameters:
87-
/// - sectionBounds: The bounds of the section to inspect.
81+
/// This property is not available on platforms such as WASI that statically
82+
/// link to the testing library.
8883
///
89-
/// - Returns: A sequence of tuples. Each tuple contains an instance of
90-
/// `__TestContentRecord` and the base address of the image containing that
91-
/// test content record. Only test content records matching this
92-
/// ``TestContent`` type's requirements are included in the sequence.
93-
private static func _testContentRecords(in sectionBounds: SectionBounds) -> some Sequence<(imageAddress: UnsafeRawPointer?, record: __TestContentRecord)> {
94-
sectionBounds.buffer.withMemoryRebound(to: __TestContentRecord.self) { records in
95-
records.lazy
96-
.filter { $0.kind == testContentKind }
97-
.map(_resign)
98-
.map { (sectionBounds.imageAddress, $0) }
99-
}
84+
/// - Note: The value of this property is distinct from the pointer returned
85+
/// by `dlopen()` (on platforms that have that function) and cannot be used
86+
/// with interfaces such as `dlsym()` that expect such a pointer.
87+
#if SWT_NO_DYNAMIC_LINKING
88+
@available(*, unavailable, message: "Image addresses are not available on this platform.")
89+
#endif
90+
nonisolated(unsafe) var imageAddress: UnsafeRawPointer?
91+
92+
/// The underlying test content record loaded from a metadata section.
93+
private var _record: __TestContentRecord
94+
95+
fileprivate init(imageAddress: UnsafeRawPointer?, record: __TestContentRecord) {
96+
#if !SWT_NO_DYNAMIC_LINKING
97+
self.imageAddress = imageAddress
98+
#endif
99+
self._record = record
100+
}
101+
}
102+
103+
// This `T: TestContent` constraint is in an extension in order to work around a
104+
// compiler crash. SEE: rdar://143049814
105+
extension TestContentRecord where T: TestContent & ~Copyable {
106+
/// The context value for this test content record.
107+
var context: UInt {
108+
_record.context
100109
}
101110

102-
/// Call the given accessor function.
111+
/// Load the value represented by this record.
103112
///
104113
/// - Parameters:
105-
/// - accessor: The C accessor function of a test content record matching
106-
/// this type.
107-
/// - hint: A pointer to a kind-specific hint value. If not `nil`, this
108-
/// value is passed to `accessor`, allowing that function to determine if
109-
/// its record matches before initializing its out-result.
114+
/// - hint: An optional hint value. If not `nil`, this value is passed to
115+
/// the accessor function of the underlying test content record.
110116
///
111-
/// - Returns: An instance of this type's accessor result or `nil` if an
112-
/// instance could not be created (or if `hint` did not match.)
117+
/// - Returns: An instance of the associated ``TestContentAccessorResult``
118+
/// type, or `nil` if the underlying test content record did not match
119+
/// `hint` or otherwise did not produce a value.
113120
///
114-
/// The caller is responsible for ensuring that `accessor` corresponds to a
115-
/// test content record of this type.
116-
private static func _callAccessor(_ accessor: SWTTestContentAccessor, withHint hint: TestContentAccessorHint?) -> TestContentAccessorResult? {
117-
withUnsafeTemporaryAllocation(of: TestContentAccessorResult.self, capacity: 1) { buffer in
121+
/// If this function is called more than once on the same instance, a new
122+
/// value is created on each call.
123+
func load(withHint hint: T.TestContentAccessorHint? = nil) -> T.TestContentAccessorResult? {
124+
guard let accessor = _record.accessor.map(swt_resign) else {
125+
return nil
126+
}
127+
128+
return withUnsafeTemporaryAllocation(of: T.TestContentAccessorResult.self, capacity: 1) { buffer in
118129
let initialized = if let hint {
119130
withUnsafePointer(to: hint) { hint in
120131
accessor(buffer.baseAddress!, hint)
@@ -128,46 +139,28 @@ extension TestContent where Self: ~Copyable {
128139
return buffer.baseAddress!.move()
129140
}
130141
}
142+
}
131143

132-
/// The type of callback called by ``enumerateTestContent(withHint:_:)``.
133-
///
134-
/// - Parameters:
135-
/// - imageAddress: A pointer to the start of the image. This value is _not_
136-
/// equal to the value returned from `dlopen()`. On platforms that do not
137-
/// support dynamic loading (and so do not have loadable images), the
138-
/// value of this argument is unspecified.
139-
/// - content: The value produced by the test content record's accessor.
140-
/// - context: Context associated with `content`. The value of this argument
141-
/// is dependent on the type of test content being enumerated.
142-
/// - stop: An `inout` boolean variable indicating whether test content
143-
/// enumeration should stop after the function returns. Set `stop` to
144-
/// `true` to stop test content enumeration.
145-
typealias TestContentEnumerator = (_ imageAddress: UnsafeRawPointer?, _ content: borrowing TestContentAccessorResult, _ context: UInt, _ stop: inout Bool) -> Void
144+
// MARK: - Enumeration of test content records
146145

147-
/// Enumerate all test content of this type known to Swift and found in the
148-
/// current process.
146+
extension TestContent where Self: ~Copyable {
147+
/// Get all test content of this type known to Swift and found in the current
148+
/// process.
149149
///
150-
/// - Parameters:
151-
/// - hint: An optional hint value. If not `nil`, this value is passed to
152-
/// the accessor function of each test content record whose `kind` field
153-
/// matches this type's ``testContentKind`` property.
154-
/// - body: A function to invoke, once per matching test content record.
150+
/// - Returns: A sequence of instances of ``TestContentRecord``. Only test
151+
/// content records matching this ``TestContent`` type's requirements are
152+
/// included in the sequence.
155153
///
156-
/// This function uses a callback instead of producing a sequence because it
157-
/// is used with move-only types (specifically ``ExitTest``) and
158-
/// `Sequence.Element` must be copyable.
159-
static func enumerateTestContent(withHint hint: TestContentAccessorHint? = nil, _ body: TestContentEnumerator) {
160-
let testContentRecords = SectionBounds.all(.testContent).lazy.flatMap(_testContentRecords(in:))
161-
162-
var stop = false
163-
for (imageAddress, record) in testContentRecords {
164-
if let accessor = record.accessor, let result = _callAccessor(accessor, withHint: hint) {
165-
// Call the callback.
166-
body(imageAddress, result, record.context, &stop)
167-
if stop {
168-
break
169-
}
154+
/// - Bug: This function returns an instance of `AnySequence` instead of an
155+
/// opaque type due to a compiler crash. ([143080508](rdar://143080508))
156+
static func allTestContentRecords() -> AnySequence<TestContentRecord<Self>> {
157+
let result = SectionBounds.all(.testContent).lazy.flatMap { sb in
158+
sb.buffer.withMemoryRebound(to: __TestContentRecord.self) { records in
159+
records.lazy
160+
.filter { $0.kind == testContentKind }
161+
.map { TestContentRecord<Self>(imageAddress: sb.imageAddress, record: $0) }
170162
}
171163
}
164+
return AnySequence(result)
172165
}
173166
}

Sources/Testing/ExitTests/ExitTest.swift

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -242,24 +242,17 @@ extension ExitTest {
242242
/// - Returns: The specified exit test function, or `nil` if no such exit test
243243
/// could be found.
244244
public static func find(identifiedBy id: ExitTest.ID) -> Self? {
245-
var result: Self?
246-
247-
enumerateTestContent(withHint: id) { _, exitTest, _, stop in
248-
if exitTest.id == id {
249-
result = ExitTest(__identifiedBy: id, body: exitTest.body)
250-
stop = true
245+
for record in Self.allTestContentRecords() {
246+
if let exitTest = record.load(withHint: id) {
247+
return exitTest
251248
}
252249
}
253250

254-
if result == nil {
255-
// Call the legacy lookup function that discovers tests embedded in types.
256-
result = types(withNamesContaining: exitTestContainerTypeNameMagic).lazy
257-
.compactMap { $0 as? any __ExitTestContainer.Type }
258-
.first { $0.__id == id }
259-
.map { ExitTest(__identifiedBy: $0.__id, body: $0.__body) }
260-
}
261-
262-
return result
251+
// Call the legacy lookup function that discovers tests embedded in types.
252+
return types(withNamesContaining: exitTestContainerTypeNameMagic).lazy
253+
.compactMap { $0 as? any __ExitTestContainer.Type }
254+
.first { $0.__id == id }
255+
.map { ExitTest(__identifiedBy: $0.__id, body: $0.__body) }
263256
}
264257
}
265258

Sources/Testing/Test+Discovery.swift

Lines changed: 27 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -22,59 +22,51 @@ extension Test: TestContent {
2222
/// The order of values in this sequence is unspecified.
2323
static var all: some Sequence<Self> {
2424
get async {
25-
var generators = [@Sendable () async -> [Self]]()
25+
// The result is a set rather than an array to deduplicate tests that were
26+
// generated multiple times (e.g. from multiple discovery modes or from
27+
// defective test records.)
28+
var result = Set<Self>()
2629

2730
// Figure out which discovery mechanism to use. By default, we'll use both
2831
// the legacy and new mechanisms, but we can set an environment variable
2932
// to explicitly select one or the other. When we remove legacy support,
3033
// we can also remove this enumeration and environment variable check.
31-
enum DiscoveryMode {
32-
case tryBoth
33-
case newOnly
34-
case legacyOnly
35-
}
36-
let discoveryMode: DiscoveryMode = switch Environment.flag(named: "SWT_USE_LEGACY_TEST_DISCOVERY") {
34+
let (useNewMode, useLegacyMode) = switch Environment.flag(named: "SWT_USE_LEGACY_TEST_DISCOVERY") {
3735
case .none:
38-
.tryBoth
36+
(true, true)
3937
case .some(true):
40-
.legacyOnly
38+
(false, true)
4139
case .some(false):
42-
.newOnly
40+
(true, false)
4341
}
4442

45-
// Walk all test content and gather generator functions. Note we don't
46-
// actually call the generators yet because enumerating test content may
47-
// involve holding some internal lock such as the ones in libobjc or
48-
// dl_iterate_phdr(), and we don't want to accidentally deadlock if the
49-
// user code we call ends up loading another image.
50-
if discoveryMode != .legacyOnly {
51-
enumerateTestContent { imageAddress, generator, _, _ in
52-
generators.append { @Sendable in
53-
await [generator()]
43+
// Walk all test content and gather generator functions, then call them in
44+
// a task group and collate their results.
45+
if useNewMode {
46+
let generators = Self.allTestContentRecords().lazy.compactMap { $0.load() }
47+
await withTaskGroup(of: Self.self) { taskGroup in
48+
for generator in generators {
49+
taskGroup.addTask(operation: generator)
5450
}
51+
result = await taskGroup.reduce(into: result) { $0.insert($1) }
5552
}
5653
}
5754

58-
if discoveryMode != .newOnly && generators.isEmpty {
59-
generators += types(withNamesContaining: testContainerTypeNameMagic).lazy
55+
// Perform legacy test discovery if needed.
56+
if useLegacyMode && result.isEmpty {
57+
let types = types(withNamesContaining: testContainerTypeNameMagic).lazy
6058
.compactMap { $0 as? any __TestContainer.Type }
61-
.map { type in
62-
{ @Sendable in await type.__tests }
63-
}
64-
}
65-
66-
// *Now* we call all the generators and return their results.
67-
// Reduce into a set rather than an array to deduplicate tests that were
68-
// generated multiple times (e.g. from multiple discovery modes or from
69-
// defective test records.)
70-
return await withTaskGroup(of: [Self].self) { taskGroup in
71-
for generator in generators {
72-
taskGroup.addTask {
73-
await generator()
59+
await withTaskGroup(of: [Self].self) { taskGroup in
60+
for type in types {
61+
taskGroup.addTask {
62+
await type.__tests
63+
}
7464
}
65+
result = await taskGroup.reduce(into: result) { $0.formUnion($1) }
7566
}
76-
return await taskGroup.reduce(into: Set()) { $0.formUnion($1) }
7767
}
68+
69+
return result
7870
}
7971
}
8072
}

Tests/TestingTests/MiscellaneousTests.swift

Lines changed: 27 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -615,43 +615,47 @@ struct MiscellaneousTests {
615615
0xABCD1234,
616616
0,
617617
{ outValue, hint in
618-
if let hint, hint.loadUnaligned(as: TestContentAccessorHint.self) != expectedHint {
618+
if let hint, hint.load(as: TestContentAccessorHint.self) != expectedHint {
619619
return false
620620
}
621621
_ = outValue.initializeMemory(as: TestContentAccessorResult.self, to: expectedResult)
622622
return true
623623
},
624-
UInt(UInt64(0x0204060801030507) & UInt64(UInt.max)),
624+
UInt(truncatingIfNeeded: UInt64(0x0204060801030507)),
625625
0
626626
)
627627
}
628628

629629
@Test func testDiscovery() async {
630-
await confirmation("Can find a single test record") { found in
631-
DiscoverableTestContent.enumerateTestContent { _, value, context, _ in
632-
if value == DiscoverableTestContent.expectedResult && context == DiscoverableTestContent.expectedContext {
633-
found()
634-
}
635-
}
636-
}
630+
// Check the type of the test record sequence (it should be lazy.)
631+
let allRecords = DiscoverableTestContent.allTestContentRecords()
632+
#if SWT_FIXED_143080508
633+
#expect(allRecords is any LazySequenceProtocol)
634+
#expect(!(allRecords is [TestContentRecord<DiscoverableTestContent>]))
635+
#endif
636+
637+
// It should have exactly one matching record (because we only emitted one.)
638+
#expect(Array(allRecords).count == 1)
639+
640+
// Can find a single test record
641+
#expect(allRecords.contains { record in
642+
record.load() == DiscoverableTestContent.expectedResult
643+
&& record.context == DiscoverableTestContent.expectedContext
644+
})
637645

638-
await confirmation("Can find a test record with matching hint") { found in
646+
// Can find a test record with matching hint
647+
#expect(allRecords.contains { record in
639648
let hint = DiscoverableTestContent.expectedHint
640-
DiscoverableTestContent.enumerateTestContent(withHint: hint) { _, value, context, _ in
641-
if value == DiscoverableTestContent.expectedResult && context == DiscoverableTestContent.expectedContext {
642-
found()
643-
}
644-
}
645-
}
649+
return record.load(withHint: hint) == DiscoverableTestContent.expectedResult
650+
&& record.context == DiscoverableTestContent.expectedContext
651+
})
646652

647-
await confirmation("Doesn't find a test record with a mismatched hint", expectedCount: 0) { found in
653+
// Doesn't find a test record with a mismatched hint
654+
#expect(!allRecords.contains { record in
648655
let hint = ~DiscoverableTestContent.expectedHint
649-
DiscoverableTestContent.enumerateTestContent(withHint: hint) { _, value, context, _ in
650-
if value == DiscoverableTestContent.expectedResult && context == DiscoverableTestContent.expectedContext {
651-
found()
652-
}
653-
}
654-
}
656+
return record.load(withHint: hint) == DiscoverableTestContent.expectedResult
657+
&& record.context == DiscoverableTestContent.expectedContext
658+
})
655659
}
656660
#endif
657661
}

0 commit comments

Comments
 (0)