Skip to content

Handle signals on Windows in exit tests. #766

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Oct 21, 2024
1 change: 1 addition & 0 deletions Sources/Testing/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ add_library(Testing
Support/Additions/CommandLineAdditions.swift
Support/Additions/NumericAdditions.swift
Support/Additions/ResultAdditions.swift
Support/Additions/WinSDKAdditions.swift
Support/CartesianProduct.swift
Support/CError.swift
Support/Environment.swift
Expand Down
12 changes: 0 additions & 12 deletions Sources/Testing/ExitTests/ExitCondition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,6 @@ public enum ExitCondition: Sendable {
/// | Linux | [`<signal.h>`](https://sourceware.org/glibc/manual/latest/html_node/Standard-Signals.html) |
/// | FreeBSD | [`<signal.h>`](https://man.freebsd.org/cgi/man.cgi?signal(3)) |
/// | Windows | [`<signal.h>`](https://learn.microsoft.com/en-us/cpp/c-runtime-library/signal-constants) |
///
/// On Windows, by default, the C runtime will terminate a process with exit
/// code `-3` if a raised signal is not handled, exactly as if `exit(-3)` were
/// called. As a result, this case is unavailable on that platform. Developers
/// should use ``failure`` instead when testing signal handling on Windows.
#if os(Windows)
@available(*, unavailable, message: "On Windows, use .failure instead.")
#endif
case signal(_ signal: CInt)
}

Expand Down Expand Up @@ -116,11 +108,9 @@ extension ExitCondition {
return switch (lhs, rhs) {
case let (.failure, .exitCode(exitCode)), let (.exitCode(exitCode), .failure):
exitCode != EXIT_SUCCESS
#if !os(Windows)
case (.failure, .signal), (.signal, .failure):
// All terminating signals are considered failures.
true
#endif
default:
lhs === rhs
}
Expand Down Expand Up @@ -194,10 +184,8 @@ extension ExitCondition {
true
case let (.exitCode(lhs), .exitCode(rhs)):
lhs == rhs
#if !os(Windows)
case let (.signal(lhs), .signal(rhs)):
lhs == rhs
#endif
default:
false
}
Expand Down
24 changes: 24 additions & 0 deletions Sources/Testing/ExitTests/ExitTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,22 @@ extension ExitTest {
public consuming func callAsFunction() async -> Never {
Self._disableCrashReporting()

#if os(Windows)
// Windows does not support signal handling to the degree UNIX-like systems
// do. When a signal is raised in a Windows process, the default signal
// handler simply calls `exit()` and passes the constant value `3`. To allow
// us to handle signals on Windows, we install signal handlers for all
// signals supported on Windows. These signal handlers exit with a specific
// exit code that is unlikely to be encountered "in the wild" and which
// encodes the caught signal. Corresponding code in the parent process looks
// for these special exit codes and translates them back to signals.
for sig in [SIGINT, SIGILL, SIGFPE, SIGSEGV, SIGTERM, SIGBREAK, SIGABRT] {
_ = signal(sig) { sig in
_Exit(STATUS_SIGNAL_CAUGHT_BITS | sig)
}
}
#endif

do {
try await body()
} catch {
Expand Down Expand Up @@ -201,6 +217,14 @@ func callExitTest(
do {
let exitTest = ExitTest(expectedExitCondition: expectedExitCondition, sourceLocation: sourceLocation)
result = try await configuration.exitTestHandler(exitTest)

#if os(Windows)
// For an explanation of this magic, see the corresponding logic in
// ExitTest.callAsFunction().
if case let .exitCode(exitCode) = result.exitCondition, (exitCode & ~STATUS_CODE_MASK) == STATUS_SIGNAL_CAUGHT_BITS {
result.exitCondition = .signal(exitCode & STATUS_CODE_MASK)
}
#endif
} catch {
// An error here would indicate a problem in the exit test handler such as a
// failure to find the process' path, to construct arguments to the
Expand Down
1 change: 0 additions & 1 deletion Sources/Testing/ExitTests/WaitFor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,6 @@ func wait(for processHandle: consuming HANDLE) async throws -> ExitCondition {
return .failure
}

// FIXME: handle SEH/VEH uncaught exceptions.
return .exitCode(CInt(bitPattern: .init(status)))
}
#else
Expand Down
48 changes: 48 additions & 0 deletions Sources/Testing/Support/Additions/WinSDKAdditions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2024 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
//

internal import _TestingInternals

#if os(Windows)
/// A bitmask that can be applied to an `HRESULT` or `NTSTATUS` value to get the
/// underlying status code.
let STATUS_CODE_MASK = NTSTATUS(0xFFFF)

/// The severity and facility bits to mask against a caught signal value before
/// terminating a child process.
///
/// For more information about the `NTSTATUS` type including its bitwise layout,
/// see [Microsoft's documentation](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-erref/87fba13e-bf06-450e-83b1-9241dc81e781).
let STATUS_SIGNAL_CAUGHT_BITS = {
var result = NTSTATUS(0)

// Set the severity and status bits.
result |= STATUS_SEVERITY_ERROR << 30
result |= 1 << 29 // "Customer" bit

// We only have 12 facility bits, but we'll pretend they spell out "s6", short
// for "Swift 6" of course.
//
// We're camping on a specific "facility" code here that we don't think is
// otherwise in use; if it conflicts with an exit test, we can add an
// environment variable lookup so callers can override us.
let FACILITY_SWIFT6 = ((NTSTATUS(UInt8(ascii: "s")) << 4) | 6)
result |= FACILITY_SWIFT6 << 16

#if DEBUG
assert(
(result & STATUS_CODE_MASK) == 0,
"Constructed NTSTATUS mask \(String(result, radix: 16)) encroached on code bits. Please file a bug report at https://github.com/swiftlang/swift-testing/issues/new"
)
#endif

return result
}()
#endif
1 change: 1 addition & 0 deletions Sources/_TestingInternals/include/Includes.h
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@
#define WIN32_LEAN_AND_MEAN
#define NOMINMAX
#include <Windows.h>
#include <ntstatus.h>
#include <Psapi.h>
#endif

Expand Down
44 changes: 12 additions & 32 deletions Tests/TestingTests/ExitTestTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,20 @@ private import _TestingInternals
await Task.yield()
exit(123)
}
#if !os(Windows)
await #expect(exitsWith: .signal(SIGKILL)) {
_ = kill(getpid(), SIGKILL)
// Allow up to 1s for the signal to be delivered.
try! await Task.sleep(nanoseconds: 1_000_000_000_000)
await #expect(exitsWith: .signal(SIGSEGV)) {
_ = raise(SIGSEGV)
// Allow up to 1s for the signal to be delivered. On some platforms,
// raise() delivers signals fully asynchronously and may not terminate the
// child process before this closure returns.
if #available(_clockAPI, *) {
try await Test.Clock.sleep(for: .seconds(1))
} else {
try await Task.sleep(nanoseconds: 1_000_000_000)
}
}
await #expect(exitsWith: .signal(SIGABRT)) {
abort()
}
#endif
#if !SWT_NO_UNSTRUCTURED_TASKS
#if false
// Test the detached (no task-local configuration) path. Disabled because,
Expand All @@ -59,13 +63,7 @@ private import _TestingInternals
}

@Test("Exit tests (failing)") func failing() async {
let expectedCount: Int
#if os(Windows)
expectedCount = 6
#else
expectedCount = 10
#endif
await confirmation("Exit tests failed", expectedCount: expectedCount) { failed in
await confirmation("Exit tests failed", expectedCount: 10) { failed in
var configuration = Configuration()
configuration.eventHandler = { event, _ in
if case .issueRecorded = event.kind {
Expand Down Expand Up @@ -105,11 +103,9 @@ private import _TestingInternals
await Test {
await #expect(exitsWith: .exitCode(EXIT_FAILURE)) {}
}.run(configuration: configuration)
#if !os(Windows)
await Test {
await #expect(exitsWith: .signal(SIGABRT)) {}
}.run(configuration: configuration)
#endif

// Mock an exit test where the process exits with a particular error code.
configuration.exitTestHandler = { _ in
Expand All @@ -119,7 +115,6 @@ private import _TestingInternals
await #expect(exitsWith: .failure) {}
}.run(configuration: configuration)

#if !os(Windows)
// Mock an exit test where the process exits with a signal.
configuration.exitTestHandler = { _ in
return ExitTest.Result(exitCondition: .signal(SIGABRT))
Expand All @@ -130,18 +125,11 @@ private import _TestingInternals
await Test {
await #expect(exitsWith: .failure) {}
}.run(configuration: configuration)
#endif
}
}

@Test("Mock exit test handlers (failing)") func failingMockHandlers() async {
let expectedCount: Int
#if os(Windows)
expectedCount = 2
#else
expectedCount = 6
#endif
await confirmation("Issue recorded", expectedCount: expectedCount) { issueRecorded in
await confirmation("Issue recorded", expectedCount: 6) { issueRecorded in
var configuration = Configuration()
configuration.eventHandler = { event, _ in
if case .issueRecorded = event.kind {
Expand All @@ -159,13 +147,10 @@ private import _TestingInternals
await Test {
await #expect(exitsWith: .exitCode(EXIT_FAILURE)) {}
}.run(configuration: configuration)
#if !os(Windows)
await Test {
await #expect(exitsWith: .signal(SIGABRT)) {}
}.run(configuration: configuration)
#endif

#if !os(Windows)
// Mock exit tests that unexpectedly signalled.
configuration.exitTestHandler = { _ in
return ExitTest.Result(exitCondition: .signal(SIGABRT))
Expand All @@ -179,7 +164,6 @@ private import _TestingInternals
await Test {
await #expect(exitsWith: .success) {}
}.run(configuration: configuration)
#endif
}
}

Expand Down Expand Up @@ -269,7 +253,6 @@ private import _TestingInternals
#expect(ExitCondition.exitCode(EXIT_FAILURE &+ 1) != .exitCode(EXIT_FAILURE))
#expect(ExitCondition.exitCode(EXIT_FAILURE &+ 1) !== .exitCode(EXIT_FAILURE))

#if !os(Windows)
#expect(ExitCondition.success != .exitCode(EXIT_FAILURE))
#expect(ExitCondition.success !== .exitCode(EXIT_FAILURE))
#expect(ExitCondition.success != .signal(SIGINT))
Expand All @@ -278,7 +261,6 @@ private import _TestingInternals
#expect(ExitCondition.signal(SIGINT) === .signal(SIGINT))
#expect(ExitCondition.signal(SIGTERM) != .signal(SIGINT))
#expect(ExitCondition.signal(SIGTERM) !== .signal(SIGINT))
#endif
}

@MainActor static func someMainActorFunction() {
Expand Down Expand Up @@ -415,7 +397,6 @@ private import _TestingInternals
exit(0)
}

#if !os(Windows)
await #expect(exitsWith: .exitCode(SIGABRT)) {
// abort() raises on Windows, but we don't handle that yet and it is
// reported as .failure (which will fuzzy-match with SIGABRT.)
Expand All @@ -428,7 +409,6 @@ private import _TestingInternals
await #expect(exitsWith: .signal(SIGSEGV)) {
abort() // sends SIGABRT, not SIGSEGV
}
#endif
}
}

Expand Down