Skip to content
Draft
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -377,8 +377,8 @@ extension Array where Element == PackageDescription.SwiftSetting {

.define("SWT_TARGET_OS_APPLE", .whenApple()),

.define("SWT_NO_EXIT_TESTS", .whenEmbedded(or: .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi, .android]))),
.define("SWT_NO_PROCESS_SPAWNING", .whenEmbedded(or: .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi, .android]))),
.define("SWT_NO_EXIT_TESTS", .whenEmbedded(or: .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi]))),
.define("SWT_NO_PROCESS_SPAWNING", .whenEmbedded(or: .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi]))),
.define("SWT_NO_SNAPSHOT_TYPES", .whenEmbedded(or: .whenApple(false))),
.define("SWT_NO_DYNAMIC_LINKING", .whenEmbedded(or: .when(platforms: [.wasi]))),
.define("SWT_NO_PIPES", .whenEmbedded(or: .when(platforms: [.wasi]))),
Expand Down Expand Up @@ -436,8 +436,8 @@ extension Array where Element == PackageDescription.CXXSetting {
var result = Self()

result += [
.define("SWT_NO_EXIT_TESTS", .whenEmbedded(or: .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi, .android]))),
.define("SWT_NO_PROCESS_SPAWNING", .whenEmbedded(or: .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi, .android]))),
.define("SWT_NO_EXIT_TESTS", .whenEmbedded(or: .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi]))),
.define("SWT_NO_PROCESS_SPAWNING", .whenEmbedded(or: .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi]))),
.define("SWT_NO_SNAPSHOT_TYPES", .whenEmbedded(or: .whenApple(false))),
.define("SWT_NO_DYNAMIC_LINKING", .whenEmbedded(or: .when(platforms: [.wasi]))),
.define("SWT_NO_PIPES", .whenEmbedded(or: .when(platforms: [.wasi]))),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,13 @@ private let _archiverPath: String? = {
/// an archive (currently of `.zip` format, although this is subject to change.)
private func _compressContentsOfDirectory(at directoryURL: URL) async throws -> Data {
#if !SWT_NO_PROCESS_SPAWNING
#if os(Android)
guard #available(Android 28, *) else {
// API level 28 corresponds to Android 9 Pie.
throw CocoaError(.featureUnsupported, userInfo: [NSLocalizedDescriptionKey: "Attaching directories to tests requires Android 9 (API level 28) or newer."])
}
#endif

let temporaryName = "\(UUID().uuidString).zip"
let temporaryURL = FileManager.default.temporaryDirectory.appendingPathComponent(temporaryName)
defer {
Expand All @@ -180,20 +187,22 @@ private func _compressContentsOfDirectory(at directoryURL: URL) async throws ->
// OpenBSD's tar(1) does not support writing PKZIP archives, and /usr/bin/zip
// tool is an optional install, so we check if it's present before trying to
// execute it.
#if os(Linux) || os(OpenBSD)
//
// TODO: figure out whether tar or zip is available on Android and where it's stored
#if os(Linux) || os(OpenBSD) || os(Android)
let archiverPath = "/bin/sh"
#if os(Linux)
#if os(Linux) || os(Android)
let trueArchiverPath = "/usr/bin/zip"
#else
let trueArchiverPath = "/usr/local/bin/zip"
#endif
var isDirectory = false
if !FileManager.default.fileExists(atPath: trueArchiverPath, isDirectory: &isDirectory) || isDirectory {
throw CocoaError(.fileNoSuchFile, userInfo: [
NSLocalizedDescriptionKey: "The 'zip' package is not installed.",
NSFilePathErrorKey: trueArchiverPath
])
}
#endif
#elseif SWT_TARGET_OS_APPLE || os(FreeBSD)
let archiverPath = "/usr/bin/tar"
#elseif os(Windows)
Expand All @@ -211,7 +220,7 @@ private func _compressContentsOfDirectory(at directoryURL: URL) async throws ->
let sourcePath = directoryURL.path
let destinationPath = temporaryURL.path
let arguments = {
#if os(Linux) || os(OpenBSD)
#if os(Linux) || os(OpenBSD) || os(Android)
// The zip command constructs relative paths from the current working
// directory rather than from command-line arguments.
["-c", #"cd "$0" && "$1" "$2" --recurse-paths ."#, sourcePath, trueArchiverPath, destinationPath]
Expand Down
15 changes: 12 additions & 3 deletions Sources/Testing/ExitTests/ExitStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ private import _TestingInternals
/// @Available(Swift, introduced: 6.2)
/// @Available(Xcode, introduced: 26.0)
/// }
#if SWT_NO_PROCESS_SPAWNING
#if !SWT_NO_PROCESS_SPAWNING
@available(Android 28, *)
#else
@_unavailableInEmbedded
@available(*, unavailable, message: "Exit tests are not available on this platform.")
#endif
public enum ExitStatus: Sendable {
Expand Down Expand Up @@ -90,7 +93,10 @@ public enum ExitStatus: Sendable {

// MARK: - Equatable

#if SWT_NO_PROCESS_SPAWNING
#if !SWT_NO_PROCESS_SPAWNING
@available(Android 28, *)
#else
@_unavailableInEmbedded
@available(*, unavailable, message: "Exit tests are not available on this platform.")
#endif
extension ExitStatus: Equatable {}
Expand All @@ -109,7 +115,10 @@ private let _sigabbrev_np = symbol(named: "sigabbrev_np").map {
}
#endif

#if SWT_NO_PROCESS_SPAWNING
#if !SWT_NO_PROCESS_SPAWNING
@available(Android 28, *)
#else
@_unavailableInEmbedded
@available(*, unavailable, message: "Exit tests are not available on this platform.")
#endif
extension ExitStatus: CustomStringConvertible {
Expand Down
4 changes: 3 additions & 1 deletion Sources/Testing/ExitTests/ExitTest.CapturedValue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
private import _TestingInternals

@_spi(ForToolsIntegrationOnly)
#if SWT_NO_EXIT_TESTS
#if !SWT_NO_EXIT_TESTS
@available(Android 28, *)
#else
@_unavailableInEmbedded
@available(*, unavailable, message: "Exit tests are not available on this platform.")
#endif
Expand Down
16 changes: 12 additions & 4 deletions Sources/Testing/ExitTests/ExitTest.Condition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@

private import _TestingInternals

#if SWT_NO_EXIT_TESTS
#if !SWT_NO_EXIT_TESTS
@available(Android 28, *)
#else
@_unavailableInEmbedded
@available(*, unavailable, message: "Exit tests are not available on this platform.")
#endif
Expand Down Expand Up @@ -58,7 +60,9 @@ extension ExitTest {

// MARK: -

#if SWT_NO_EXIT_TESTS
#if !SWT_NO_EXIT_TESTS
@available(Android 28, *)
#else
@_unavailableInEmbedded
@available(*, unavailable, message: "Exit tests are not available on this platform.")
#endif
Expand Down Expand Up @@ -178,7 +182,9 @@ extension ExitTest.Condition {

// MARK: - CustomStringConvertible

#if SWT_NO_EXIT_TESTS
#if !SWT_NO_EXIT_TESTS
@available(Android 28, *)
#else
@_unavailableInEmbedded
@available(*, unavailable, message: "Exit tests are not available on this platform.")
#endif
Expand All @@ -201,7 +207,9 @@ extension ExitTest.Condition: CustomStringConvertible {

// MARK: - Comparison

#if SWT_NO_EXIT_TESTS
#if !SWT_NO_EXIT_TESTS
@available(Android 28, *)
#else
@_unavailableInEmbedded
@available(*, unavailable, message: "Exit tests are not available on this platform.")
#endif
Expand Down
4 changes: 3 additions & 1 deletion Sources/Testing/ExitTests/ExitTest.Result.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
//

#if SWT_NO_EXIT_TESTS
#if !SWT_NO_EXIT_TESTS
@available(Android 28, *)
#else
@_unavailableInEmbedded
@available(*, unavailable, message: "Exit tests are not available on this platform.")
#endif
Expand Down
8 changes: 6 additions & 2 deletions Sources/Testing/ExitTests/ExitTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ private import _TestingInternals
/// @Available(Swift, introduced: 6.2)
/// @Available(Xcode, introduced: 26.0)
/// }
#if SWT_NO_EXIT_TESTS
#if !SWT_NO_EXIT_TESTS
@available(Android 28, *)
#else
@_unavailableInEmbedded
@available(*, unavailable, message: "Exit tests are not available on this platform.")
#endif
Expand Down Expand Up @@ -223,6 +225,8 @@ extension ExitTest {
// as I can tell, special-case RLIMIT_CORE=1.
var rl = rlimit(rlim_cur: 0, rlim_max: 0)
_ = setrlimit(RLIMIT_CORE, &rl)
#elseif os(Android)
// TODO: "tombstoned_intercept"?
#elseif os(Windows)
// On Windows, similarly disable Windows Error Reporting and the Windows
// Error Reporting UI. Note we expect to be the first component to call
Expand Down Expand Up @@ -698,7 +702,7 @@ extension ExitTest {
/// back to a (new) file handle with `_makeFileHandle()`, or `nil` if the
/// file handle could not be converted to a string.
private static func _makeEnvironmentVariable(for fileHandle: borrowing FileHandle) -> String? {
#if SWT_TARGET_OS_APPLE || os(Linux) || os(FreeBSD) || os(OpenBSD)
#if SWT_TARGET_OS_APPLE || os(Linux) || os(FreeBSD) || os(OpenBSD) || os(Android)
return fileHandle.withUnsafePOSIXFileDescriptor { fd in
fd.map(String.init(describing:))
}
Expand Down
27 changes: 20 additions & 7 deletions Sources/Testing/ExitTests/SpawnProcess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ internal import _TestingInternals

/// A platform-specific value identifying a process running on the current
/// system.
#if SWT_TARGET_OS_APPLE || os(Linux) || os(FreeBSD) || os(OpenBSD)
#if SWT_TARGET_OS_APPLE || os(Linux) || os(FreeBSD) || os(OpenBSD) || os(Android)
typealias ProcessID = pid_t
#elseif os(Windows)
typealias ProcessID = HANDLE
Expand Down Expand Up @@ -62,6 +62,7 @@ private let _posix_spawn_file_actions_addclosefrom_np = symbol(named: "posix_spa
/// resources.
///
/// - Throws: Any error that prevented the process from spawning.
@available(Android 28, *)
func spawnExecutable(
atPath executablePath: String,
arguments: [String],
Expand All @@ -71,18 +72,26 @@ func spawnExecutable(
standardError: borrowing FileHandle? = nil,
additionalFileHandles: [UnsafePointer<FileHandle>] = []
) throws -> ProcessID {
// Darwin and Linux differ in their optionality for the posix_spawn types we
// use, so use this typealias to paper over the differences.
// Darwin, the BSDs, Linux, and Android all differ in their optionality for
// the posix_spawn types we use, so use this typealias and helper function to
// paper over the differences.
#if SWT_TARGET_OS_APPLE || os(FreeBSD) || os(OpenBSD)
typealias P<T> = T?
func asArgument<T>(_ p: UnsafeMutableBufferPointer<T?>) -> UnsafeMutablePointer<T?> { p.baseAddress! }
#elseif os(Linux)
typealias P<T> = T
func asArgument<T>(_ p: UnsafeMutableBufferPointer<T>) -> UnsafeMutablePointer<T> { p.baseAddress! }
#elseif os(Android)
typealias P<T> = T?
func asArgument<T>(_ p: UnsafeMutableBufferPointer<T?>) -> UnsafeMutablePointer<T> {
UnsafeMutableRawPointer(p.baseAddress!).bindMemory(to: T.self, capacity: 1)
}
#endif

#if SWT_TARGET_OS_APPLE || os(Linux) || os(FreeBSD) || os(OpenBSD)
return try withUnsafeTemporaryAllocation(of: P<posix_spawn_file_actions_t>.self, capacity: 1) { fileActions in
let fileActions = fileActions.baseAddress!
let fileActionsInitialized = posix_spawn_file_actions_init(fileActions)
let fileActionsInitialized = posix_spawn_file_actions_init(fileActions.baseAddress!)
let fileActions = asArgument(fileActions)
guard 0 == fileActionsInitialized else {
throw CError(rawValue: fileActionsInitialized)
}
Expand All @@ -91,8 +100,8 @@ func spawnExecutable(
}

return try withUnsafeTemporaryAllocation(of: P<posix_spawnattr_t>.self, capacity: 1) { attrs in
let attrs = attrs.baseAddress!
let attrsInitialized = posix_spawnattr_init(attrs)
let attrsInitialized = posix_spawnattr_init(attrs.baseAddress!)
let attrs = asArgument(attrs)
guard 0 == attrsInitialized else {
throw CError(rawValue: attrsInitialized)
}
Expand Down Expand Up @@ -194,6 +203,9 @@ func spawnExecutable(
// spawned child process if we control its execution.
var environment = environment
environment["SWT_CLOSEFROM"] = String(describing: highestFD + 1)
#elseif os(Android)
// Android does not have posix_spawn_file_actions_addclosefrom_np() nor
// closefrom(2), so we don't attempt this operation there.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Android has close_range (which closefrom is typically trivially implemented in terms of). See swiftlang/swift-subprocess#172

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

close_range() requires an upper bound, which we don't know. (Also setting CLOSE_RANGE_CLOEXEC is a good way to break code that opened a higher fd and doesn't want FD_CLOEXEC set.)

Copy link

@jakepetroules jakepetroules Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea is that you pass ~0U (max int) as the upper bound, e.g. when reading SWT_CLOSEFROM, call close_range(x, ~0U) instead of closefrom(x). That is how closefrom is implemented internally on the platforms where it exists anyways -- close_range is the syscall, where the kernel can clamp the upper bound down to the actual highest open fd, while closefrom is only in libc.

#else
#warning("Platform-specific implementation missing: cannot close unused file descriptors")
#endif
Expand Down Expand Up @@ -463,6 +475,7 @@ private func _escapeCommandLine(_ arguments: [String]) -> String {
/// This function is a convenience that spawns the given process and waits for
/// it to terminate. It is primarily for use by other targets in this package
/// such as its cross-import overlays.
@available(Android 28, *)
package func spawnExecutableAtPathAndWait(
_ executablePath: String,
arguments: [String] = [],
Expand Down
18 changes: 11 additions & 7 deletions Sources/Testing/ExitTests/WaitFor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
#if !SWT_NO_PROCESS_SPAWNING
internal import _TestingInternals

#if SWT_TARGET_OS_APPLE || os(Linux) || os(FreeBSD) || os(OpenBSD)
#if SWT_TARGET_OS_APPLE || os(Linux) || os(FreeBSD) || os(OpenBSD) || os(Android)
/// Block the calling thread, wait for the target process to exit, and return
/// a value describing the conditions under which it exited.
///
Expand All @@ -29,9 +29,9 @@ private func _blockAndWait(for pid: consuming pid_t) throws -> ExitStatus {
if 0 == waitid(P_PID, id_t(pid), &siginfo, WEXITED) {
switch siginfo.si_code {
case .init(CLD_EXITED):
return .exitCode(siginfo.si_status)
return .exitCode(swt_siginfo_t_si_status(siginfo))
case .init(CLD_KILLED), .init(CLD_DUMPED):
return .signal(siginfo.si_status)
return .signal(swt_siginfo_t_si_status(siginfo))
default:
throw SystemError(description: "Unexpected siginfo_t value. Please file a bug report at https://github.com/swiftlang/swift-testing/issues/new and include this information: \(String(reflecting: siginfo))")
}
Expand Down Expand Up @@ -78,7 +78,7 @@ func wait(for pid: consuming pid_t) async throws -> ExitStatus {

return try _blockAndWait(for: pid)
}
#elseif SWT_TARGET_OS_APPLE || os(Linux) || os(FreeBSD) || os(OpenBSD)
#elseif SWT_TARGET_OS_APPLE || os(Linux) || os(FreeBSD) || os(OpenBSD) || os(Android)
/// A mapping of awaited child PIDs to their corresponding Swift continuations.
private nonisolated(unsafe) let _childProcessContinuations = {
let result = ManagedBuffer<[pid_t: CheckedContinuation<ExitStatus, any Error>], pthread_mutex_t>.create(
Expand Down Expand Up @@ -146,7 +146,7 @@ private let _createWaitThread: Void = {
// continuation (if available) before reaping.
var siginfo = siginfo_t()
if 0 == waitid(P_ALL, 0, &siginfo, WEXITED | WNOWAIT) {
if case let pid = siginfo.si_pid, pid != 0 {
if case let pid = swt_siginfo_t_si_pid(siginfo), pid != 0 {
let continuation = _withLockedChildProcessContinuations { childProcessContinuations, _ in
childProcessContinuations.removeValue(forKey: pid)
}
Expand Down Expand Up @@ -189,8 +189,9 @@ private let _createWaitThread: Void = {
{ _ in
// Set the thread name to help with diagnostics. Note that different
// platforms support different thread name lengths. See MAXTHREADNAMESIZE
// on Darwin, TASK_COMM_LEN on Linux, MAXCOMLEN on FreeBSD, and _MAXCOMLEN
// on OpenBSD. We try to maximize legibility in the available space.
// on Darwin, TASK_COMM_LEN on Linux, MAXCOMLEN on FreeBSD, _MAXCOMLEN on
// OpenBSD, and MAX_TASK_COMM_LEN on Android. We try to maximize
// legibility in the available space.
#if SWT_TARGET_OS_APPLE
_ = pthread_setname_np("Swift Testing exit test monitor")
#elseif os(Linux)
Expand All @@ -201,6 +202,8 @@ private let _createWaitThread: Void = {
pthread_set_name_np(pthread_self(), "SWT ex test monitor")
#elseif os(OpenBSD)
pthread_set_name_np(pthread_self(), "SWT exit test monitor")
#elseif os(Android)
_ = pthread_setname_np(pthread_self(), "SWT ExT monitor")
#else
#warning("Platform-specific implementation missing: thread naming unavailable")
#endif
Expand Down Expand Up @@ -233,6 +236,7 @@ private let _createWaitThread: Void = {
///
/// On Apple platforms, the libdispatch-based implementation above is more
/// efficient because it does not need to permanently reserve a thread.
@available(Android 28, *)
func wait(for pid: consuming pid_t) async throws -> ExitStatus {
let pid = consume pid

Expand Down
8 changes: 6 additions & 2 deletions Sources/Testing/Expectations/Expectation+Macro.swift
Original file line number Diff line number Diff line change
Expand Up @@ -876,7 +876,9 @@ public macro require<R>(
/// }
@freestanding(expression)
@discardableResult
#if SWT_NO_EXIT_TESTS
#if !SWT_NO_EXIT_TESTS
@available(Android 28, *)
#else
@_unavailableInEmbedded
@available(*, unavailable, message: "Exit tests are not available on this platform.")
#endif
Expand Down Expand Up @@ -924,7 +926,9 @@ public macro expect(
/// }
@freestanding(expression)
@discardableResult
#if SWT_NO_EXIT_TESTS
#if !SWT_NO_EXIT_TESTS
@available(Android 28, *)
#else
@_unavailableInEmbedded
@available(*, unavailable, message: "Exit tests are not available on this platform.")
#endif
Expand Down
Loading