Skip to content

Commit 324f6b8

Browse files
committed
Disable pipes code on WASI. (#699)
WASI does not support pipes. Resolves #698. ### 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 265a7ed commit 324f6b8

File tree

18 files changed

+516
-172
lines changed

18 files changed

+516
-172
lines changed

Package.swift

+1
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ extension Array where Element == PackageDescription.SwiftSetting {
133133
.define("SWT_NO_EXIT_TESTS", .when(platforms: [.iOS, .watchOS, .tvOS, .visionOS, .wasi, .android])),
134134
.define("SWT_NO_SNAPSHOT_TYPES", .when(platforms: [.linux, .windows, .wasi])),
135135
.define("SWT_NO_DYNAMIC_LINKING", .when(platforms: [.wasi])),
136+
.define("SWT_NO_PIPES", .when(platforms: [.wasi])),
136137
]
137138
}
138139

Sources/Testing/ABI/EntryPoints/ABIEntryPoint.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ private func entryPoint(
120120
args?.eventStreamVersion = eventStreamVersionIfNil
121121
}
122122

123-
let eventHandler = try eventHandlerForStreamingEvents(version: args?.eventStreamVersion, forwardingTo: recordHandler)
123+
let eventHandler = try eventHandlerForStreamingEvents(version: args?.eventStreamVersion, encodeAsJSONLines: false, forwardingTo: recordHandler)
124124
let exitCode = await entryPoint(passing: args, eventHandler: eventHandler)
125125

126126
// To maintain compatibility with Xcode 16 Beta 1, suppress custom exit codes.

Sources/Testing/ABI/EntryPoints/EntryPoint.swift

+16-50
Original file line numberDiff line numberDiff line change
@@ -468,8 +468,11 @@ public func configurationForEntryPoint(from args: __CommandLineArguments_v0) thr
468468
// Event stream output (experimental)
469469
if let eventStreamOutputPath = args.eventStreamOutputPath {
470470
let file = try FileHandle(forWritingAtPath: eventStreamOutputPath)
471-
let eventHandler = try eventHandlerForStreamingEvents(version: args.eventStreamVersion) { json in
472-
try? _writeJSONLine(json, to: file)
471+
let eventHandler = try eventHandlerForStreamingEvents(version: args.eventStreamVersion, encodeAsJSONLines: true) { json in
472+
_ = try? file.withLock {
473+
try file.write(json)
474+
try file.write("\n")
475+
}
473476
}
474477
configuration.eventHandler = { [oldEventHandler = configuration.eventHandler] event, context in
475478
eventHandler(event, context)
@@ -536,13 +539,20 @@ public func configurationForEntryPoint(from args: __CommandLineArguments_v0) thr
536539
///
537540
/// - Parameters:
538541
/// - version: The ABI version to use.
542+
/// - encodeAsJSONLines: Whether or not to ensure JSON passed to
543+
/// `eventHandler` is encoded as JSON Lines (i.e. that it does not contain
544+
/// extra newlines.)
539545
/// - eventHandler: The event handler to forward encoded events to. The
540546
/// encoding of events depends on `version`.
541547
///
542548
/// - Returns: An event handler.
543549
///
544550
/// - Throws: If `version` is not a supported ABI version.
545-
func eventHandlerForStreamingEvents(version: Int?, forwardingTo eventHandler: @escaping @Sendable (UnsafeRawBufferPointer) -> Void) throws -> Event.Handler {
551+
func eventHandlerForStreamingEvents(
552+
version: Int?,
553+
encodeAsJSONLines: Bool,
554+
forwardingTo eventHandler: @escaping @Sendable (UnsafeRawBufferPointer) -> Void
555+
) throws -> Event.Handler {
546556
switch version {
547557
#if !SWT_NO_SNAPSHOT_TYPES
548558
case -1:
@@ -551,57 +561,11 @@ func eventHandlerForStreamingEvents(version: Int?, forwardingTo eventHandler: @e
551561
eventHandlerForStreamingEventSnapshots(to: eventHandler)
552562
#endif
553563
case nil, 0:
554-
ABIv0.Record.eventHandler(forwardingTo: eventHandler)
564+
ABIv0.Record.eventHandler(encodeAsJSONLines: encodeAsJSONLines, forwardingTo: eventHandler)
555565
case let .some(unsupportedVersion):
556566
throw _EntryPointError.invalidArgument("--event-stream-version", value: "\(unsupportedVersion)")
557567
}
558568
}
559-
560-
/// Post-process encoded JSON and write it to a file.
561-
///
562-
/// - Parameters:
563-
/// - json: The JSON to write.
564-
/// - file: The file to write to.
565-
///
566-
/// - Throws: Whatever is thrown when writing to `file`.
567-
private func _writeJSONLine(_ json: UnsafeRawBufferPointer, to file: borrowing FileHandle) throws {
568-
func isASCIINewline(_ byte: UInt8) -> Bool {
569-
byte == UInt8(ascii: "\r") || byte == UInt8(ascii: "\n")
570-
}
571-
572-
func write(_ json: UnsafeRawBufferPointer) throws {
573-
try file.withLock {
574-
try file.write(json)
575-
try file.write("\n")
576-
}
577-
}
578-
579-
// We don't actually expect the JSON encoder to produce output containing
580-
// newline characters, so in debug builds we'll log a diagnostic message.
581-
if _slowPath(json.contains(where: isASCIINewline)) {
582-
#if DEBUG
583-
let message = Event.ConsoleOutputRecorder.warning(
584-
"JSON encoder produced one or more newline characters while encoding an event to JSON. Please file a bug report at https://github.com/swiftlang/swift-testing/issues/new",
585-
options: .for(.stderr)
586-
)
587-
#if SWT_TARGET_OS_APPLE
588-
try? FileHandle.stderr.write(message)
589-
#else
590-
print(message)
591-
#endif
592-
#endif
593-
594-
// Remove the newline characters to conform to JSON lines specification.
595-
var json = Array(json)
596-
json.removeAll(where: isASCIINewline)
597-
try json.withUnsafeBytes { json in
598-
try write(json)
599-
}
600-
} else {
601-
// No newlines found, no need to copy the buffer.
602-
try write(json)
603-
}
604-
}
605569
#endif
606570

607571
// MARK: - Command-line interface options
@@ -660,12 +624,14 @@ extension Event.ConsoleOutputRecorder.Options {
660624
return true
661625
}
662626

627+
#if !SWT_NO_PIPES
663628
// If the file handle is a pipe, assume the other end is using it to forward
664629
// output from this process to its own stderr file. This is how `swift test`
665630
// invokes the testing library, for example.
666631
if fileHandle.isPipe {
667632
return true
668633
}
634+
#endif
669635

670636
return false
671637
}

Sources/Testing/ABI/v0/ABIv0.Record+Streaming.swift

+44-1
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,46 @@
1010

1111
#if canImport(Foundation) && (!SWT_NO_FILE_IO || !SWT_NO_ABI_ENTRY_POINT)
1212
extension ABIv0.Record {
13+
/// Post-process encoded JSON and write it to a file.
14+
///
15+
/// - Parameters:
16+
/// - json: The JSON to write.
17+
/// - file: The file to write to.
18+
///
19+
/// - Throws: Whatever is thrown when writing to `file`.
20+
private static func _asJSONLine(_ json: UnsafeRawBufferPointer, _ eventHandler: (_ recordJSON: UnsafeRawBufferPointer) throws -> Void) rethrows {
21+
// We don't actually expect the JSON encoder to produce output containing
22+
// newline characters, so in debug builds we'll log a diagnostic message.
23+
if _slowPath(json.contains(where: \.isASCIINewline)) {
24+
#if DEBUG
25+
let message = Event.ConsoleOutputRecorder.warning(
26+
"JSON encoder produced one or more newline characters while encoding an event to JSON. Please file a bug report at https://github.com/swiftlang/swift-testing/issues/new",
27+
options: .for(.stderr)
28+
)
29+
#if SWT_TARGET_OS_APPLE
30+
try? FileHandle.stderr.write(message)
31+
#else
32+
print(message)
33+
#endif
34+
#endif
35+
36+
// Remove the newline characters to conform to JSON lines specification.
37+
var json = Array(json)
38+
json.removeAll(where: \.isASCIINewline)
39+
try json.withUnsafeBytes(eventHandler)
40+
} else {
41+
// No newlines found, no need to copy the buffer.
42+
try eventHandler(json)
43+
}
44+
}
45+
1346
/// Create an event handler that encodes events as JSON and forwards them to
1447
/// an ABI-friendly event handler.
1548
///
1649
/// - Parameters:
50+
/// - encodeAsJSONLines: Whether or not to ensure JSON passed to
51+
/// `eventHandler` is encoded as JSON Lines (i.e. that it does not contain
52+
/// extra newlines.)
1753
/// - eventHandler: The event handler to forward events to. See
1854
/// ``ABIv0/EntryPoint-swift.typealias`` for more information.
1955
///
@@ -27,10 +63,17 @@ extension ABIv0.Record {
2763
/// performs additional postprocessing before writing JSON data to ensure it
2864
/// does not contain any newline characters.
2965
static func eventHandler(
66+
encodeAsJSONLines: Bool,
3067
forwardingTo eventHandler: @escaping @Sendable (_ recordJSON: UnsafeRawBufferPointer) -> Void
3168
) -> Event.Handler {
69+
// Encode as JSON Lines if requested.
70+
var eventHandlerCopy = eventHandler
71+
if encodeAsJSONLines {
72+
eventHandlerCopy = { @Sendable in _asJSONLine($0, eventHandler) }
73+
}
74+
3275
let humanReadableOutputRecorder = Event.HumanReadableOutputRecorder()
33-
return { event, context in
76+
return { [eventHandler = eventHandlerCopy] event, context in
3477
if case .testDiscovered = event.kind, let test = context.test {
3578
try? JSON.withEncoding(of: Self(encoding: test)) { testJSON in
3679
eventHandler(testJSON)

Sources/Testing/ExitTests/ExitTest.swift

+153-6
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
private import _TestingInternals
1212

1313
#if !SWT_NO_EXIT_TESTS
14+
#if SWT_NO_PIPES
15+
#error("Support for exit tests requires support for (anonymous) pipes.")
16+
#endif
17+
1418
/// A type describing an exit test.
1519
///
1620
/// Instances of this type describe an exit test defined by the test author and
@@ -62,6 +66,32 @@ public struct ExitTest: Sendable {
6266
#endif
6367
}
6468

69+
/// Find a back channel file handle set up by the parent process.
70+
///
71+
/// - Returns: A file handle open for writing to which events should be
72+
/// written, or `nil` if the file handle could not be resolved.
73+
private static func _findBackChannel() -> FileHandle? {
74+
guard let backChannelEnvironmentVariable = Environment.variable(named: "SWT_EXPERIMENTAL_BACKCHANNEL_FD") else {
75+
return nil
76+
}
77+
78+
var fd: CInt?
79+
#if SWT_TARGET_OS_APPLE || os(Linux) || os(FreeBSD)
80+
fd = CInt(backChannelEnvironmentVariable).map(dup)
81+
#elseif os(Windows)
82+
if let handle = UInt(backChannelEnvironmentVariable).flatMap(HANDLE.init(bitPattern:)) {
83+
fd = _open_osfhandle(Int(bitPattern: handle), _O_WRONLY | _O_BINARY)
84+
}
85+
#else
86+
#warning("Platform-specific implementation missing: back-channel pipe unavailable")
87+
#endif
88+
guard let fd, fd >= 0 else {
89+
return nil
90+
}
91+
92+
return try? FileHandle(unsafePOSIXFileDescriptor: fd, mode: "wb")
93+
}
94+
6595
/// Call the exit test in the current process.
6696
///
6797
/// This function invokes the closure originally passed to
@@ -72,8 +102,27 @@ public struct ExitTest: Sendable {
72102
public func callAsFunction() async -> Never {
73103
Self._disableCrashReporting()
74104

105+
// Set up the configuration for this process.
106+
var configuration = Configuration()
107+
if let backChannel = Self._findBackChannel() {
108+
// Encode events as JSON and write them to the back channel file handle.
109+
var eventHandler = ABIv0.Record.eventHandler(encodeAsJSONLines: true) { json in
110+
try? backChannel.write(json)
111+
}
112+
113+
// Only forward issue-recorded events. (If we start handling other kinds
114+
// of event in the future, we can forward them too.)
115+
eventHandler = { [eventHandler] event, eventContext in
116+
if case .issueRecorded = event.kind {
117+
eventHandler(event, eventContext)
118+
}
119+
}
120+
121+
configuration.eventHandler = eventHandler
122+
}
123+
75124
do {
76-
try await body()
125+
try await Configuration.withCurrent(configuration, perform: body)
77126
} catch {
78127
_errorInMain(error)
79128
}
@@ -338,11 +387,109 @@ extension ExitTest {
338387
childEnvironment["SWT_EXPERIMENTAL_EXIT_TEST_SOURCE_LOCATION"] = String(decoding: json, as: UTF8.self)
339388
}
340389

341-
return try await spawnAndWait(
342-
forExecutableAtPath: childProcessExecutablePath,
343-
arguments: childArguments,
344-
environment: childEnvironment
345-
)
390+
return try await withThrowingTaskGroup(of: ExitCondition?.self) { taskGroup in
391+
// Create a "back channel" pipe to handle events from the child process.
392+
let backChannel = try FileHandle.Pipe()
393+
394+
// Let the child process know how to find the back channel by setting a
395+
// known environment variable to the corresponding file descriptor
396+
// (HANDLE on Windows.)
397+
var backChannelEnvironmentVariable: String?
398+
#if SWT_TARGET_OS_APPLE || os(Linux) || os(FreeBSD)
399+
backChannelEnvironmentVariable = backChannel.writeEnd.withUnsafePOSIXFileDescriptor { fd in
400+
fd.map(String.init(describing:))
401+
}
402+
#elseif os(Windows)
403+
backChannelEnvironmentVariable = backChannel.writeEnd.withUnsafeWindowsHANDLE { handle in
404+
handle.flatMap { String(describing: UInt(bitPattern: $0)) }
405+
}
406+
#else
407+
#warning("Platform-specific implementation missing: back-channel pipe unavailable")
408+
#endif
409+
if let backChannelEnvironmentVariable {
410+
childEnvironment["SWT_EXPERIMENTAL_BACKCHANNEL_FD"] = backChannelEnvironmentVariable
411+
}
412+
413+
// Spawn the child process.
414+
let processID = try withUnsafePointer(to: backChannel.writeEnd) { writeEnd in
415+
try spawnExecutable(
416+
atPath: childProcessExecutablePath,
417+
arguments: childArguments,
418+
environment: childEnvironment,
419+
additionalFileHandles: .init(start: writeEnd, count: 1)
420+
)
421+
}
422+
423+
// Await termination of the child process.
424+
taskGroup.addTask {
425+
try await wait(for: processID)
426+
}
427+
428+
// Read back all data written to the back channel by the child process
429+
// and process it as a (minimal) event stream.
430+
let readEnd = backChannel.closeWriteEnd()
431+
taskGroup.addTask {
432+
Self._processRecordsFromBackChannel(readEnd)
433+
return nil
434+
}
435+
436+
// This is a roundabout way of saying "and return the exit condition
437+
// yielded by wait(for:)".
438+
return try await taskGroup.compactMap { $0 }.first { _ in true }!
439+
}
440+
}
441+
}
442+
443+
/// Read lines from the given back channel file handle and process them as
444+
/// event records.
445+
///
446+
/// - Parameters:
447+
/// - backChannel: The file handle to read from. Reading continues until an
448+
/// error is encountered or the end of the file is reached.
449+
private static func _processRecordsFromBackChannel(_ backChannel: borrowing FileHandle) {
450+
let bytes: [UInt8]
451+
do {
452+
bytes = try backChannel.readToEnd()
453+
} catch {
454+
// NOTE: an error caught here indicates an I/O problem.
455+
// TODO: should we record these issues as systemic instead?
456+
Issue.record(error)
457+
return
458+
}
459+
460+
for recordJSON in bytes.split(whereSeparator: \.isASCIINewline) where !recordJSON.isEmpty {
461+
do {
462+
try recordJSON.withUnsafeBufferPointer { recordJSON in
463+
try Self._processRecord(.init(recordJSON), fromBackChannel: backChannel)
464+
}
465+
} catch {
466+
// NOTE: an error caught here indicates a decoding problem.
467+
// TODO: should we record these issues as systemic instead?
468+
Issue.record(error)
469+
}
470+
}
471+
}
472+
473+
/// Decode a line of JSON read from a back channel file handle and handle it
474+
/// as if the corresponding event occurred locally.
475+
///
476+
/// - Parameters:
477+
/// - recordJSON: The JSON to decode and process.
478+
/// - backChannel: The file handle that `recordJSON` was read from.
479+
///
480+
/// - Throws: Any error encountered attempting to decode or process the JSON.
481+
private static func _processRecord(_ recordJSON: UnsafeRawBufferPointer, fromBackChannel backChannel: borrowing FileHandle) throws {
482+
let record = try JSON.decode(ABIv0.Record.self, from: recordJSON)
483+
484+
if case let .event(event) = record.kind, let issue = event.issue {
485+
// Translate the issue back into a "real" issue and record it
486+
// in the parent process. This translation is, of course, lossy
487+
// due to the process boundary, but we make a best effort.
488+
let comments: [Comment] = event.messages.compactMap { message in
489+
message.symbol == .details ? Comment(rawValue: message.text) : nil
490+
}
491+
let issue = Issue(kind: .unconditional, comments: comments, sourceContext: .init(backtrace: nil, sourceLocation: issue.sourceLocation))
492+
issue.record()
346493
}
347494
}
348495
}

0 commit comments

Comments
 (0)