Skip to content

Commit db980db

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 81a5ccb commit db980db

File tree

9 files changed

+424
-117
lines changed

9 files changed

+424
-117
lines changed

Sources/Testing/ExitTests/ExitTest.swift

Lines changed: 149 additions & 6 deletions
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)