Skip to content

Commit

Permalink
Store test content in a custom metadata section.
Browse files Browse the repository at this point in the history
This PR uses the experimental symbol linkage margers feature in the Swift
compiler to emit metadata about tests (and exit tests) into a dedicated section
of the test executable being built. At runtime, we discover that section and
read out the tests from it.

This has several benefits over our current model, which involves walking Swift's
type metadata table looking for types that conform to a protocol:

1. We don't need to define that protocol as public API in Swift Testing,
1. We don't need to emit type metadata (much larger than what we really need)
   for every test function,
1. We don't need to duplicate a large chunk of the Swift ABI sources in order to
   walk the type metadata table correctly, and
1. Almost all the new code is written in Swift, whereas the code it is intended
   to replace could not be fully represented in Swift and needed to be written
   in C++.

The change also opens up the possibility of supporting generic types in the
future because we can emit metadata without needing to emit a nested type (which
is not always valid in a generic context.) That's a "future direction" and not
covered by this PR specifically.

I've defined a layout for entries in the new `swift5_tests` section that should
be flexible enough for us in the short-to-medium term and which lets us define
additional arbitrary test content record types. The layout of this section is
covered in depth in the new [TestContent.md](Documentation/ABI/TestContent.md)
article.

This functionality is only available if a test target enables the experimental
`"SymbolLinkageMarkers"` feature. We continue to emit protocol-conforming types
for now—that code will be removed if and when the experimental feature is
properly supported (modulo us adopting relevant changes to the feature's API.)

#735
swiftlang/swift#76698
swiftlang/swift#78411
  • Loading branch information
grynspan committed Feb 23, 2025
1 parent e76a44f commit 69d0ba1
Show file tree
Hide file tree
Showing 21 changed files with 445 additions and 86 deletions.
6 changes: 3 additions & 3 deletions Documentation/ABI/TestContent.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,16 +75,16 @@ struct SWTTestContentRecord {
};
```

Do not use the `__TestContentRecord` or `__TestContentRecordAccessor` typealias
defined in the testing library. These types exist to support the testing
Do not use the `__TestContentRecord` or `__TestContentRecordAccessor` type
aliases defined in the testing library. These types exist to support the testing
library's macros and may change in the future (e.g. to accomodate a generic
argument or to make use of a reserved field.)

Instead, define your own copy of these types where needed—you can copy the
definitions above _verbatim_. If your test record type's `context` field (as
described below) is a pointer type, make sure to change its type in your version
of `TestContentRecord` accordingly so that, on systems with pointer
authentication enabled, the pointer is correctly resigned at load time.
authentication enabled, the pointer is correctly re-signed at load time.

### Record content

Expand Down
6 changes: 6 additions & 0 deletions Documentation/Porting.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,10 @@ to load that information:
+ let resourceName: Str255 = switch kind {
+ case .testContent:
+ "__swift5_tests"
+#if !SWT_NO_LEGACY_TEST_DISCOVERY
+ case .typeMetadata:
+ "__swift5_types"
+#endif
+ }
+
+ let oldRefNum = CurResFile()
Expand Down Expand Up @@ -219,15 +221,19 @@ diff --git a/Sources/_TestingInternals/Discovery.cpp b/Sources/_TestingInternals
+#elif defined(macintosh)
+extern "C" const char testContentSectionBegin __asm__("...");
+extern "C" const char testContentSectionEnd __asm__("...");
+#if !defined(SWT_NO_LEGACY_TEST_DISCOVERY)
+extern "C" const char typeMetadataSectionBegin __asm__("...");
+extern "C" const char typeMetadataSectionEnd __asm__("...");
+#endif
#else
#warning Platform-specific implementation missing: Runtime test discovery unavailable (static)
static const char testContentSectionBegin = 0;
static const char& testContentSectionEnd = testContentSectionBegin;
#if !defined(SWT_NO_LEGACY_TEST_DISCOVERY)
static const char typeMetadataSectionBegin = 0;
static const char& typeMetadataSectionEnd = testContentSectionBegin;
#endif
#endif
```

These symbols must have unique addresses corresponding to the first byte of the
Expand Down
2 changes: 2 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@ extension Array where Element == PackageDescription.SwiftSetting {
.enableExperimentalFeature("AccessLevelOnImport"),
.enableUpcomingFeature("InternalImportsByDefault"),

.enableExperimentalFeature("SymbolLinkageMarkers"),

.define("SWT_TARGET_OS_APPLE", .when(platforms: [.macOS, .iOS, .macCatalyst, .watchOS, .tvOS, .visionOS])),

.define("SWT_NO_EXIT_TESTS", .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi, .android])),
Expand Down
11 changes: 9 additions & 2 deletions Sources/Testing/Discovery+Platform.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@ struct SectionBounds: Sendable {
/// The test content metadata section.
case testContent

#if !SWT_NO_LEGACY_TEST_DISCOVERY
/// The type metadata section.
case typeMetadata
#endif
}

/// All section bounds of the given kind found in the current process.
Expand Down Expand Up @@ -60,8 +62,10 @@ extension SectionBounds.Kind {
switch self {
case .testContent:
("__DATA_CONST", "__swift5_tests")
#if !SWT_NO_LEGACY_TEST_DISCOVERY
case .typeMetadata:
("__TEXT", "__swift5_types")
#endif
}
}
}
Expand Down Expand Up @@ -101,9 +105,8 @@ private let _startCollectingSectionBounds: Void = {
var size = CUnsignedLong(0)
if let start = getsectiondata(mh, segmentName.utf8Start, sectionName.utf8Start, &size), size > 0 {
let buffer = UnsafeRawBufferPointer(start: start, count: Int(clamping: size))
let sb = SectionBounds(imageAddress: mh, buffer: buffer)
_sectionBounds.withLock { sectionBounds in
sectionBounds[kind]!.append(sb)
sectionBounds[kind]!.append(SectionBounds(imageAddress: mh, buffer: buffer))
}
}
}
Expand Down Expand Up @@ -165,8 +168,10 @@ private func _sectionBounds(_ kind: SectionBounds.Kind) -> [SectionBounds] {
let range = switch context.pointee.kind {
case .testContent:
sections.swift5_tests
#if !SWT_NO_LEGACY_TEST_DISCOVERY
case .typeMetadata:
sections.swift5_type_metadata
#endif
}
let start = UnsafeRawPointer(bitPattern: range.start)
let size = Int(clamping: range.length)
Expand Down Expand Up @@ -255,8 +260,10 @@ private func _sectionBounds(_ kind: SectionBounds.Kind) -> some Sequence<Section
let sectionName = switch kind {
case .testContent:
".sw5test"
#if !SWT_NO_LEGACY_TEST_DISCOVERY
case .typeMetadata:
".sw5tymd"
#endif
}
return HMODULE.all.lazy.compactMap { _findSection(named: sectionName, in: $0) }
}
Expand Down
17 changes: 17 additions & 0 deletions Sources/Testing/Discovery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,23 @@ public typealias __TestContentRecord = (
reserved2: UInt
)

/// Check if the type at the given address is equal to a given Swift type.
///
/// - Parameters:
/// - typeAddress: A pointer to a Swift type, as in the `type` argument to a
/// test content record accessor function.
/// - type: The type expected to be at `typeAddress`.
///
/// - Returns: Whether or not the type at `typeAddress` equals `type`.
///
/// - Warning: This function is used to implement the `@Test`, `@Suite`, and
/// `#expect(exitsWith:)` macros. Do not use it directly.
public func __type(at typeAddress: UnsafeRawPointer, is type: (some ~Copyable).Type) -> Bool {
// `typeAddress` may actually point to a move-only type, but attempting to
// load it as such leads to a crash. SEE: rdar://134277439
TypeInfo(describing: typeAddress.load(as: Any.Type.self)) == TypeInfo(describing: type)
}

// MARK: -

/// A protocol describing a type that can be stored as test content at compile
Expand Down
68 changes: 46 additions & 22 deletions Sources/Testing/ExitTests/ExitTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,7 @@ public struct __ExitTest: Sendable, ~Copyable {
private var _lo: UInt64
private var _hi: UInt64

/// Initialize an instance of this type.
///
/// - Warning: This member is used to implement the `#expect(exitsWith:)`
/// macro. Do not use it directly.
public init(__uuid uuid: (UInt64, UInt64)) {
init(_ uuid: (UInt64, UInt64)) {
self._lo = uuid.0
self._hi = uuid.1
}
Expand All @@ -77,7 +73,7 @@ public struct __ExitTest: Sendable, ~Copyable {
/// Do not invoke this closure directly. Instead, invoke ``callAsFunction()``
/// to run the exit test. Running the exit test will always terminate the
/// current process.
fileprivate var body: @Sendable () async throws -> Void
fileprivate var body: @Sendable () async throws -> Void = {}

/// Storage for ``observedValues``.
///
Expand Down Expand Up @@ -113,18 +109,6 @@ public struct __ExitTest: Sendable, ~Copyable {
_observedValues = newValue
}
}

/// Initialize an exit test at runtime.
///
/// - Warning: This initializer is used to implement the `#expect(exitsWith:)`
/// macro. Do not use it directly.
public init(
__identifiedBy id: ID,
body: @escaping @Sendable () async throws -> Void = {}
) {
self.id = id
self.body = body
}
}

#if !SWT_NO_EXIT_TESTS
Expand Down Expand Up @@ -229,6 +213,42 @@ extension ExitTest: TestContent {
}

typealias TestContentAccessorHint = ID

/// Store the test generator function into the given memory.
///
/// - Parameters:
/// - outValue: The uninitialized memory to store the exit test into.
/// - id: The unique identifier of the exit test to store.
/// - body: The body closure of the exit test to store. This value is passed
/// as a thin function to avoid a heap allocation when the exit test does
/// not match the hint (which is common during linear searches.)
/// - typeAddress: A pointer to the expected type of the exit test as passed
/// to the test content record calling this function.
/// - hint: A pointer to an instance of ``ID`` to use as a hint.
///
/// - Returns: Whether or not an exit test was stored into `outValue`.
///
/// - Warning: This function is used to implement the `#expect(exitsWith:)`
/// macro. Do not use it directly.
public static func __store(
_ id: (UInt64, UInt64),
_ body: @convention(thin) @Sendable () async throws -> Void,
into outValue: UnsafeMutableRawPointer,
asTypeAt typeAddress: UnsafeRawPointer,
withHintAt hint: UnsafeRawPointer? = nil
) -> CBool {
let callerExpectedType = TypeInfo(describing: typeAddress.load(as: (any ~Copyable.Type).self))
let selfType = TypeInfo(describing: Self.self)
guard callerExpectedType == selfType else {
return false
}
let id = ID(id)
if let hintedID = hint?.load(as: ID.self), hintedID != id {
return false
}
outValue.initializeMemory(as: Self.self, to: Self(id: id, body: { try await body() }))
return true
}
}

@_spi(Experimental) @_spi(ForToolsIntegrationOnly)
Expand All @@ -247,11 +267,15 @@ extension ExitTest {
}
}

#if !SWT_NO_LEGACY_TEST_DISCOVERY
// Call the legacy lookup function that discovers tests embedded in types.
return types(withNamesContaining: exitTestContainerTypeNameMagic).lazy
.compactMap { $0 as? any __ExitTestContainer.Type }
.first { $0.__id == id }
.map { ExitTest(__identifiedBy: $0.__id, body: $0.__body) }
.first { ID($0.__id) == id }
.map { ExitTest(id: ID($0.__id), body: $0.__body) }
#else
return nil
#endif
}
}

Expand Down Expand Up @@ -280,7 +304,7 @@ extension ExitTest {
/// `await #expect(exitsWith:) { }` invocations regardless of calling
/// convention.
func callExitTest(
identifiedBy exitTestID: ExitTest.ID,
identifiedBy exitTestID: (UInt64, UInt64),
exitsWith expectedExitCondition: ExitCondition,
observing observedValues: [any PartialKeyPath<ExitTestArtifacts> & Sendable],
expression: __Expression,
Expand All @@ -295,7 +319,7 @@ func callExitTest(

var result: ExitTestArtifacts
do {
var exitTest = ExitTest(__identifiedBy: exitTestID)
var exitTest = ExitTest(id: ExitTest.ID(exitTestID))
exitTest.observedValues = observedValues
result = try await configuration.exitTestHandler(exitTest)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1147,7 +1147,7 @@ public func __checkClosureCall<R>(
/// `#require()` macros. Do not call it directly.
@_spi(Experimental)
public func __checkClosureCall(
identifiedBy exitTestID: __ExitTest.ID,
identifiedBy exitTestID: (UInt64, UInt64),
exitsWith expectedExitCondition: ExitCondition,
observing observedValues: [any PartialKeyPath<ExitTestArtifacts> & Sendable],
performing body: @convention(thin) () -> Void,
Expand Down
4 changes: 3 additions & 1 deletion Sources/Testing/Test+Discovery+Legacy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

private import _TestingInternals

#if !SWT_NO_LEGACY_TEST_DISCOVERY
/// A protocol describing a type that contains tests.
///
/// - Warning: This protocol is used to implement the `@Test` macro. Do not use
Expand All @@ -33,7 +34,7 @@ let testContainerTypeNameMagic = "__🟠$test_container__"
@_spi(Experimental)
public protocol __ExitTestContainer {
/// The unique identifier of the exit test.
static var __id: __ExitTest.ID { get }
static var __id: (UInt64, UInt64) { get }

/// The body function of the exit test.
static var __body: @Sendable () async throws -> Void { get }
Expand All @@ -60,3 +61,4 @@ func types(withNamesContaining nameSubstring: String) -> some Sequence<Any.Type>
.map { unsafeBitCast($0, to: Any.Type.self) }
}
}
#endif
46 changes: 41 additions & 5 deletions Sources/Testing/Test+Discovery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@
private import _TestingInternals

extension Test {
/// The type of the actual (asynchronous) generator function produced by test
/// content records.
///
/// - Warning: This type is used to implement the `@Test` macro. Do not use it
/// directly.
public typealias __Generator = @Sendable () async -> Test

/// A type that encapsulates test content records that produce instances of
/// ``Test``.
///
Expand All @@ -29,14 +36,37 @@ extension Test {
}

static var testContentAccessorTypeArgument: any ~Copyable.Type {
Generator.self
__Generator.self
}

/// The type of the actual (asynchronous) generator function.
typealias Generator = @Sendable () async -> Test

/// The actual (asynchronous) accessor function.
case generator(Generator)
case generator(__Generator)
}

/// Store the test generator function into the given memory.
///
/// - Parameters:
/// - generator: The generator function to store.
/// - outValue: The uninitialized memory to store `generator` into.
/// - typeAddress: A pointer to the expected type of `generator` as passed
/// to the test content record calling this function.
/// - hint: Ignored. Pass `nil`.
///
/// - Returns: Whether or not `generator` was stored into `outValue`.
///
/// - Warning: This function is used to implement the `@Test` macro. Do not
/// use it directly.
public static func __store(
_ generator: @escaping __Generator,
into outValue: UnsafeMutableRawPointer,
asTypeAt typeAddress: UnsafeRawPointer,
withHintAt hint: UnsafeRawPointer? = nil
) -> CBool {
guard typeAddress.load(as: Any.Type.self) == __Generator.self else {
return false
}
outValue.initializeMemory(as: _Record.self, to: .generator(generator))
return true
}

/// All available ``Test`` instances in the process, according to the runtime.
Expand All @@ -53,6 +83,7 @@ extension Test {
// the legacy and new mechanisms, but we can set an environment variable
// to explicitly select one or the other. When we remove legacy support,
// we can also remove this enumeration and environment variable check.
#if !SWT_NO_LEGACY_TEST_DISCOVERY
let (useNewMode, useLegacyMode) = switch Environment.flag(named: "SWT_USE_LEGACY_TEST_DISCOVERY") {
case .none:
(true, true)
Expand All @@ -61,6 +92,9 @@ extension Test {
case .some(false):
(true, false)
}
#else
let useNewMode = true
#endif

// Walk all test content and gather generator functions, then call them in
// a task group and collate their results.
Expand All @@ -79,6 +113,7 @@ extension Test {
}
}

#if !SWT_NO_LEGACY_TEST_DISCOVERY
// Perform legacy test discovery if needed.
if useLegacyMode && result.isEmpty {
let types = types(withNamesContaining: testContainerTypeNameMagic).lazy
Expand All @@ -92,6 +127,7 @@ extension Test {
result = await taskGroup.reduce(into: result) { $0.formUnion($1) }
}
}
#endif

return result
}
Expand Down
2 changes: 2 additions & 0 deletions Sources/TestingMacros/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ target_sources(TestingMacros PRIVATE
Support/Additions/DeclGroupSyntaxAdditions.swift
Support/Additions/EditorPlaceholderExprSyntaxAdditions.swift
Support/Additions/FunctionDeclSyntaxAdditions.swift
Support/Additions/IntegerLiteralExprSyntaxAdditions.swift
Support/Additions/MacroExpansionContextAdditions.swift
Support/Additions/TokenSyntaxAdditions.swift
Support/Additions/TriviaPieceAdditions.swift
Expand All @@ -103,6 +104,7 @@ target_sources(TestingMacros PRIVATE
Support/DiagnosticMessage+Diagnosing.swift
Support/SourceCodeCapturing.swift
Support/SourceLocationGeneration.swift
Support/TestContentGeneration.swift
TagMacro.swift
TestDeclarationMacro.swift
TestingMacrosMain.swift)
Expand Down
Loading

0 comments on commit 69d0ba1

Please sign in to comment.