Skip to content

Commit 87e4034

Browse files
authored
Remove exit tests' dependency on Process. (#354)
This PR directly calls `posix_spawn()` (`CreateProcessW()` on Windows) to spawn an exit test's child process instead of relying on Foundation's `Process` API. I'm making this change to reduce our dependencies on Foundation. ### 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 d494fab commit 87e4034

File tree

4 files changed

+339
-20
lines changed

4 files changed

+339
-20
lines changed

Sources/Testing/ExitTests/ExitTest.swift

Lines changed: 151 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ extension ExitTest {
266266
}()
267267

268268
return { exitTest in
269-
let childProcessURL = try URL(fileURLWithPath: childProcessExecutablePath.get(), isDirectory: false)
269+
let childProcessExecutablePath = try childProcessExecutablePath.get()
270270

271271
// Inherit the environment from the parent process and make any necessary
272272
// platform-specific changes.
@@ -288,31 +288,162 @@ extension ExitTest {
288288
// to run.
289289
childEnvironment["SWT_EXPERIMENTAL_EXIT_TEST_SOURCE_LOCATION"] = try String(data: JSONEncoder().encode(exitTest.sourceLocation), encoding: .utf8)!
290290

291-
let (actualExitCode, wasSignalled) = try await withCheckedThrowingContinuation { continuation in
292-
let process = Process()
293-
process.executableURL = childProcessURL
294-
process.arguments = childArguments
295-
process.environment = childEnvironment
296-
process.terminationHandler = { process in
297-
continuation.resume(returning: (process.terminationStatus, process.terminationReason == .uncaughtSignal))
291+
return try await _spawnAndWait(
292+
forExecutableAtPath: childProcessExecutablePath,
293+
arguments: childArguments,
294+
environment: childEnvironment
295+
)
296+
}
297+
}
298+
299+
/// Spawn a process and wait for it to terminate.
300+
///
301+
/// - Parameters:
302+
/// - executablePath: The path to the executable to spawn.
303+
/// - arguments: The arguments to pass to the executable, not including the
304+
/// executable path.
305+
/// - environment: The environment block to pass to the executable.
306+
///
307+
/// - Returns: The exit condition of the spawned process.
308+
///
309+
/// - Throws: Any error that prevented the process from spawning or its exit
310+
/// condition from being read.
311+
private static func _spawnAndWait(
312+
forExecutableAtPath executablePath: String,
313+
arguments: [String],
314+
environment: [String: String]
315+
) async throws -> ExitCondition {
316+
// Darwin and Linux differ in their optionality for the posix_spawn types we
317+
// use, so use this typealias to paper over the differences.
318+
#if SWT_TARGET_OS_APPLE
319+
typealias P<T> = T?
320+
#elseif os(Linux)
321+
typealias P<T> = T
322+
#endif
323+
324+
#if SWT_TARGET_OS_APPLE || os(Linux)
325+
let pid = try withUnsafeTemporaryAllocation(of: P<posix_spawn_file_actions_t>.self, capacity: 1) { fileActions in
326+
guard 0 == posix_spawn_file_actions_init(fileActions.baseAddress!) else {
327+
throw CError(rawValue: swt_errno())
328+
}
329+
defer {
330+
_ = posix_spawn_file_actions_destroy(fileActions.baseAddress!)
331+
}
332+
333+
// Do not forward standard I/O.
334+
_ = posix_spawn_file_actions_addopen(fileActions.baseAddress!, STDIN_FILENO, "/dev/null", O_RDONLY, 0)
335+
_ = posix_spawn_file_actions_addopen(fileActions.baseAddress!, STDOUT_FILENO, "/dev/null", O_WRONLY, 0)
336+
_ = posix_spawn_file_actions_addopen(fileActions.baseAddress!, STDERR_FILENO, "/dev/null", O_WRONLY, 0)
337+
338+
return try withUnsafeTemporaryAllocation(of: P<posix_spawnattr_t>.self, capacity: 1) { attrs in
339+
guard 0 == posix_spawnattr_init(attrs.baseAddress!) else {
340+
throw CError(rawValue: swt_errno())
298341
}
299-
do {
300-
try process.run()
301-
} catch {
302-
continuation.resume(throwing: error)
342+
defer {
343+
_ = posix_spawnattr_destroy(attrs.baseAddress!)
344+
}
345+
#if SWT_TARGET_OS_APPLE
346+
// Close all other file descriptors open in the parent. Note that Linux
347+
// does not support this flag and, unlike Foundation.Process, we do not
348+
// attempt to emulate it.
349+
_ = posix_spawnattr_setflags(attrs.baseAddress!, CShort(POSIX_SPAWN_CLOEXEC_DEFAULT))
350+
#endif
351+
352+
var argv: [UnsafeMutablePointer<CChar>?] = [strdup(executablePath)]
353+
argv += arguments.lazy.map { strdup($0) }
354+
argv.append(nil)
355+
defer {
356+
for arg in argv {
357+
free(arg)
358+
}
303359
}
360+
361+
var environ: [UnsafeMutablePointer<CChar>?] = environment.map { strdup("\($0.key)=\($0.value)") }
362+
environ.append(nil)
363+
defer {
364+
for environ in environ {
365+
free(environ)
366+
}
367+
}
368+
369+
var pid = pid_t()
370+
guard 0 == posix_spawn(&pid, executablePath, fileActions.baseAddress!, attrs.baseAddress, argv, environ) else {
371+
throw CError(rawValue: swt_errno())
372+
}
373+
return pid
304374
}
375+
}
305376

306-
if wasSignalled {
307-
#if os(Windows)
308-
// Actually an uncaught SEH/VEH exception (which we don't model yet.)
309-
return .failure
310-
#else
311-
return .signal(actualExitCode)
312-
#endif
377+
return try await wait(for: pid)
378+
#elseif os(Windows)
379+
// NOTE: Windows processes are responsible for handling their own
380+
// command-line escaping. This code is adapted from the code in
381+
// swift-corelibs-foundation (SEE: quoteWindowsCommandLine()) which was
382+
// itself adapted from the code published by Microsoft at
383+
// https://learn.microsoft.com/en-gb/archive/blogs/twistylittlepassagesallalike/everyone-quotes-command-line-arguments-the-wrong-way
384+
let commandLine = (CollectionOfOne(executablePath) + arguments).lazy
385+
.map { arg in
386+
if !arg.contains(where: {" \t\n\"".contains($0)}) {
387+
return arg
388+
}
389+
390+
var quoted = "\""
391+
var unquoted = arg.unicodeScalars
392+
while !unquoted.isEmpty {
393+
guard let firstNonBackslash = unquoted.firstIndex(where: { $0 != "\\" }) else {
394+
let backslashCount = unquoted.count
395+
quoted.append(String(repeating: "\\", count: backslashCount * 2))
396+
break
397+
}
398+
let backslashCount = unquoted.distance(from: unquoted.startIndex, to: firstNonBackslash)
399+
if (unquoted[firstNonBackslash] == "\"") {
400+
quoted.append(String(repeating: "\\", count: backslashCount * 2 + 1))
401+
quoted.append(String(unquoted[firstNonBackslash]))
402+
} else {
403+
quoted.append(String(repeating: "\\", count: backslashCount))
404+
quoted.append(String(unquoted[firstNonBackslash]))
405+
}
406+
unquoted.removeFirst(backslashCount + 1)
407+
}
408+
quoted.append("\"")
409+
return quoted
410+
}.joined(separator: " ")
411+
let environ = environment.map { "\($0.key)=\($0.value)"}.joined(separator: "\0") + "\0\0"
412+
413+
let processHandle: HANDLE! = try commandLine.withCString(encodedAs: UTF16.self) { commandLine in
414+
try environ.withCString(encodedAs: UTF16.self) { environ in
415+
var processInfo = PROCESS_INFORMATION()
416+
417+
var startupInfo = STARTUPINFOW()
418+
startupInfo.cb = DWORD(MemoryLayout.size(ofValue: startupInfo))
419+
guard CreateProcessW(
420+
nil,
421+
.init(mutating: commandLine),
422+
nil,
423+
nil,
424+
false,
425+
DWORD(CREATE_NO_WINDOW | CREATE_UNICODE_ENVIRONMENT),
426+
.init(mutating: environ),
427+
nil,
428+
&startupInfo,
429+
&processInfo
430+
) else {
431+
throw Win32Error(rawValue: GetLastError())
432+
}
433+
_ = CloseHandle(processInfo.hThread)
434+
435+
return processInfo.hProcess
313436
}
314-
return .exitCode(actualExitCode)
315437
}
438+
defer {
439+
CloseHandle(processHandle)
440+
}
441+
442+
return try await wait(for: processHandle)
443+
#else
444+
#warning("Platform-specific implementation missing: process spawning unavailable")
445+
throw SystemError(description: "Exit tests are unimplemented on this platform.")
446+
#endif
316447
}
317448
}
318449
#endif
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
//
2+
// This source file is part of the Swift.org open source project
3+
//
4+
// Copyright (c) 2024 Apple Inc. and the Swift project authors
5+
// Licensed under Apache License v2.0 with Runtime Library Exception
6+
//
7+
// See https://swift.org/LICENSE.txt for license information
8+
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
//
10+
11+
#if !SWT_NO_EXIT_TESTS
12+
internal import TestingInternals
13+
14+
#if SWT_TARGET_OS_APPLE || os(Linux)
15+
/// Wait for a given PID to exit and report its status.
16+
///
17+
/// - Parameters:
18+
/// - pid: The PID to wait for.
19+
///
20+
/// - Returns: The exit condition of `pid`.
21+
///
22+
/// - Throws: Any error encountered calling `waitpid()` except for `EINTR`,
23+
/// which is ignored.
24+
///
25+
/// This function blocks the calling thread on `waitpid()`. External callers
26+
/// should use ``wait(for:)`` instead to avoid deadlocks.
27+
private func _blockAndWait(for pid: pid_t) throws -> ExitCondition {
28+
while true {
29+
var status: CInt = 0
30+
if waitpid(pid, &status, 0) >= 0 {
31+
if swt_WIFSIGNALED(status) {
32+
return .signal(swt_WTERMSIG(status))
33+
} else if swt_WIFEXITED(status) {
34+
return .exitCode(swt_WEXITSTATUS(status))
35+
} else {
36+
// Unreachable: neither signalled nor exited, but waitpid()
37+
// and libdispatch indicate that the process has died.
38+
throw SystemError(description: "Unexpected waitpid() result \(status). Please file a bug report at https://github.com/apple/swift-testing/issues/new")
39+
}
40+
} else if swt_errno() != EINTR {
41+
throw CError(rawValue: swt_errno())
42+
}
43+
}
44+
}
45+
46+
/// Wait for a given PID to exit and report its status.
47+
///
48+
/// - Parameters:
49+
/// - pid: The PID to wait for.
50+
///
51+
/// - Returns: The exit condition of `pid`.
52+
///
53+
/// - Throws: Any error encountered calling `waitpid()` except for `EINTR`,
54+
/// which is ignored.
55+
func wait(for pid: pid_t) async throws -> ExitCondition {
56+
#if SWT_TARGET_OS_APPLE
57+
let source = DispatchSource.makeProcessSource(identifier: pid, eventMask: .exit)
58+
defer {
59+
source.cancel()
60+
}
61+
await withCheckedContinuation { continuation in
62+
source.setEventHandler {
63+
continuation.resume()
64+
}
65+
source.resume()
66+
}
67+
withExtendedLifetime(source) {}
68+
return try _blockAndWait(for: pid)
69+
#else
70+
// On Linux, spin up a background thread and waitpid() there.
71+
return try await withCheckedThrowingContinuation { continuation in
72+
// Create a structure to hold the state needed by the thread, and box it
73+
// as a refcounted pointer that we can pass to libpthread.
74+
struct Context {
75+
var pid: pid_t
76+
var continuation: CheckedContinuation<ExitCondition, any Error>
77+
}
78+
let context = Unmanaged.passRetained(
79+
Context(pid: pid, continuation: continuation) as AnyObject
80+
).toOpaque()
81+
82+
// The body of the thread: unwrap and take ownership of the context we
83+
// created above, then call waitpid() and report the result/error.
84+
let body: @convention(c) (UnsafeMutableRawPointer?) -> UnsafeMutableRawPointer? = { contextp in
85+
let context = Unmanaged<AnyObject>.fromOpaque(contextp!).takeRetainedValue() as! Context
86+
let result = Result { try _blockAndWait(for: context.pid) }
87+
context.continuation.resume(with: result)
88+
return nil
89+
}
90+
91+
// Create the thread. We immediately detach it upon success to allow the
92+
// system to reclaim its resources when done.
93+
var thread = pthread_t()
94+
switch pthread_create(&thread, nil, body, context) {
95+
case 0:
96+
_ = pthread_detach(thread)
97+
case let errorCode:
98+
continuation.resume(throwing: CError(rawValue: errorCode))
99+
}
100+
}
101+
#endif
102+
}
103+
#elseif os(Windows)
104+
/// Wait for a given process handle to exit and report its status.
105+
///
106+
/// - Parameters:
107+
/// - processHandle: The handle to wait for.
108+
///
109+
/// - Returns: The exit condition of `processHandle`.
110+
///
111+
/// - Throws: Any error encountered calling `WaitForSingleObject()` or
112+
/// `GetExitCodeProcess()`.
113+
///
114+
/// This function blocks the calling thread on `WaitForSingleObject()`. External
115+
/// callers should use ``wait(for:)`` instead to avoid deadlocks.
116+
private func _blockAndWait(for processHandle: HANDLE) throws -> ExitCondition {
117+
if WAIT_FAILED == WaitForSingleObject(processHandle, INFINITE) {
118+
throw Win32Error(rawValue: GetLastError())
119+
}
120+
var status: DWORD = 0
121+
guard GetExitCodeProcess(processHandle, &status) else {
122+
// The child process terminated but we couldn't get its status back.
123+
// Assume generic failure.
124+
return .failure
125+
}
126+
127+
// FIXME: handle SEH/VEH uncaught exceptions.
128+
return .exitCode(CInt(bitPattern: status))
129+
}
130+
131+
/// Wait for a given process handle to exit and report its status.
132+
///
133+
/// - Parameters:
134+
/// - processHandle: The handle to wait for.
135+
///
136+
/// - Returns: The exit condition of `processHandle`.
137+
///
138+
/// - Throws: Any error encountered calling `WaitForSingleObject()` or
139+
/// `GetExitCodeProcess()`.
140+
func wait(for processHandle: HANDLE) async throws -> ExitCondition {
141+
try await withCheckedThrowingContinuation { continuation in
142+
// Create a structure to hold the state needed by the thread, and box it
143+
// as a refcounted pointer that we can pass to libpthread.
144+
struct Context {
145+
var processHandle: HANDLE
146+
var continuation: CheckedContinuation<ExitCondition, any Error>
147+
}
148+
let context = Unmanaged.passRetained(
149+
Context(processHandle: processHandle, continuation: continuation) as AnyObject
150+
).toOpaque()
151+
152+
let body: _beginthread_proc_type = { contextp in
153+
let context = Unmanaged<AnyObject>.fromOpaque(contextp!).takeRetainedValue() as! Context
154+
let result = Result { try _blockAndWait(for: context.processHandle) }
155+
context.continuation.resume(with: result)
156+
}
157+
_ = _beginthread(body, 0, context)
158+
}
159+
}
160+
#endif
161+
#endif

Sources/TestingInternals/include/Includes.h

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@
3535
#include <string.h>
3636
#include <time.h>
3737

38+
#if defined(__APPLE__) && __has_include(<dispatch/dispatch.h>)
39+
#include <dispatch/dispatch.h>
40+
#endif
41+
3842
#if __has_include(<unistd.h>)
3943
#include <unistd.h>
4044
#endif
@@ -47,6 +51,10 @@
4751
#include <sys/sysctl.h>
4852
#endif
4953

54+
#if __has_include(<sys/wait.h>)
55+
#include <sys/wait.h>
56+
#endif
57+
5058
#if __has_include(<sys/utsname.h>)
5159
#include <sys/utsname.h>
5260
#endif

0 commit comments

Comments
 (0)