Skip to content

Commit c635ea3

Browse files
committed
Record issues generated within exit tests.
This PR introduces a "back channel" file handle to exit tests, allowing us to record issues that occur within exit test bodies. For example: ```swift await #expect(exitsWith: .failure) { let context = try #require(possiblyMissingContext) ... } ``` In this example, if the call to `try #require()` finds `nil`, it will record an issue, but that issue today will be lost because there's no mechanism to forward the issue back to the parent process hosting the exit test. This PR fixes that! Issues are converted to JSON using the same schema we use for event handling, then written over a pipe back to the parent process where they are decoded. This decoding is lossy, so there will be further refinement needed here to try to preserve more information about the recorded issues. That said, "it's got good bones" right? On Darwin, Linux, and FreeBSD, the pipe's write end is allowed to survive into the child process (i.e. no `FD_CLOEXEC`). On Windows, the equivalent is to tell `CreateProcessW()` to explicitly inherit a `HANDLE`. The identity of this file descriptor or handle is passed to the child process via environment variable. The child process then parses the file descriptor or handle out of the environment and converts it back to a `FileHandle` that is then connected to an instance of `Configuration` with an event handler set, and off we go. Because we can now report these issues back to the parent process, I've removed the compile-time diagnostic in the `#expect(exitsWith:)` macro implementation that we emit when we see a nested `#expect()` or `#require()` call.
1 parent 4e7dd97 commit c635ea3

File tree

14 files changed

+493
-170
lines changed

14 files changed

+493
-170
lines changed

Diff for: 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.

Diff for: Sources/Testing/ABI/EntryPoints/EntryPoint.swift

+14-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

Diff for: 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)

Diff for: Sources/Testing/ExitTests/ExitTest.swift

+149-6
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,32 @@ public struct ExitTest: Sendable {
6666
#endif
6767
}
6868

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+
6995
/// Call the exit test in the current process.
7096
///
7197
/// This function invokes the closure originally passed to
@@ -76,8 +102,27 @@ public struct ExitTest: Sendable {
76102
public func callAsFunction() async -> Never {
77103
Self._disableCrashReporting()
78104

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+
79124
do {
80-
try await body()
125+
try await Configuration.withCurrent(configuration, perform: body)
81126
} catch {
82127
_errorInMain(error)
83128
}
@@ -342,11 +387,109 @@ extension ExitTest {
342387
childEnvironment["SWT_EXPERIMENTAL_EXIT_TEST_SOURCE_LOCATION"] = String(decoding: json, as: UTF8.self)
343388
}
344389

345-
return try await spawnAndWait(
346-
forExecutableAtPath: childProcessExecutablePath,
347-
arguments: childArguments,
348-
environment: childEnvironment
349-
)
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()
350493
}
351494
}
352495
}

0 commit comments

Comments
 (0)