Skip to content

Commit d7d2427

Browse files
grynspancompnerd
andauthored
Handle signals on Windows in exit tests. (#766)
This PR adds support for handling signals as distinct exit conditions on Windows. Previously, we didn't support them because Windows itself has minimal support. However, through the judicious use of "stuffing a bunch of bits into a 32-bit field", we are able to propagate raised signals out of the child process and detect them in the parent process. We do this by installing our own signal handlers for all signals supported on Windows, then masking the caught signal against an `NTSTATUS` severity and facility that are unlikely to be reported by the system in practice. In the parent process, we look for exit codes that match these values and extract the signal from them when found. Because the namespace for exit codes on Windows is shared with uncaught [VEH/SEH exceptions](https://learn.microsoft.com/en-us/cpp/cpp/structured-exception-handling-c-cpp) (which are propagated to the parent process as `NTSTATUS` codes), there is the potential for conflict with some hypothetical _real_ exception code, but I'm taking a gamble here that my choice of `NTSTATUS` facility is unique. (Bonus points for there not being convenient macros to construct an `NTSTATUS` code anywhere in the Windows SDK I can find.) ### 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. --------- Co-authored-by: Saleem Abdulrasool <[email protected]>
1 parent 404f121 commit d7d2427

File tree

7 files changed

+86
-45
lines changed

7 files changed

+86
-45
lines changed

Sources/Testing/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ add_library(Testing
6666
Support/Additions/CommandLineAdditions.swift
6767
Support/Additions/NumericAdditions.swift
6868
Support/Additions/ResultAdditions.swift
69+
Support/Additions/WinSDKAdditions.swift
6970
Support/CartesianProduct.swift
7071
Support/CError.swift
7172
Support/Environment.swift

Sources/Testing/ExitTests/ExitCondition.swift

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -67,14 +67,6 @@ public enum ExitCondition: Sendable {
6767
/// | Linux | [`<signal.h>`](https://sourceware.org/glibc/manual/latest/html_node/Standard-Signals.html) |
6868
/// | FreeBSD | [`<signal.h>`](https://man.freebsd.org/cgi/man.cgi?signal(3)) |
6969
/// | Windows | [`<signal.h>`](https://learn.microsoft.com/en-us/cpp/c-runtime-library/signal-constants) |
70-
///
71-
/// On Windows, by default, the C runtime will terminate a process with exit
72-
/// code `-3` if a raised signal is not handled, exactly as if `exit(-3)` were
73-
/// called. As a result, this case is unavailable on that platform. Developers
74-
/// should use ``failure`` instead when testing signal handling on Windows.
75-
#if os(Windows)
76-
@available(*, unavailable, message: "On Windows, use .failure instead.")
77-
#endif
7870
case signal(_ signal: CInt)
7971
}
8072

@@ -116,11 +108,9 @@ extension ExitCondition {
116108
return switch (lhs, rhs) {
117109
case let (.failure, .exitCode(exitCode)), let (.exitCode(exitCode), .failure):
118110
exitCode != EXIT_SUCCESS
119-
#if !os(Windows)
120111
case (.failure, .signal), (.signal, .failure):
121112
// All terminating signals are considered failures.
122113
true
123-
#endif
124114
default:
125115
lhs === rhs
126116
}
@@ -194,10 +184,8 @@ extension ExitCondition {
194184
true
195185
case let (.exitCode(lhs), .exitCode(rhs)):
196186
lhs == rhs
197-
#if !os(Windows)
198187
case let (.signal(lhs), .signal(rhs)):
199188
lhs == rhs
200-
#endif
201189
default:
202190
false
203191
}

Sources/Testing/ExitTests/ExitTest.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,22 @@ extension ExitTest {
101101
public consuming func callAsFunction() async -> Never {
102102
Self._disableCrashReporting()
103103

104+
#if os(Windows)
105+
// Windows does not support signal handling to the degree UNIX-like systems
106+
// do. When a signal is raised in a Windows process, the default signal
107+
// handler simply calls `exit()` and passes the constant value `3`. To allow
108+
// us to handle signals on Windows, we install signal handlers for all
109+
// signals supported on Windows. These signal handlers exit with a specific
110+
// exit code that is unlikely to be encountered "in the wild" and which
111+
// encodes the caught signal. Corresponding code in the parent process looks
112+
// for these special exit codes and translates them back to signals.
113+
for sig in [SIGINT, SIGILL, SIGFPE, SIGSEGV, SIGTERM, SIGBREAK, SIGABRT] {
114+
_ = signal(sig) { sig in
115+
_Exit(STATUS_SIGNAL_CAUGHT_BITS | sig)
116+
}
117+
}
118+
#endif
119+
104120
do {
105121
try await body()
106122
} catch {
@@ -201,6 +217,14 @@ func callExitTest(
201217
do {
202218
let exitTest = ExitTest(expectedExitCondition: expectedExitCondition, sourceLocation: sourceLocation)
203219
result = try await configuration.exitTestHandler(exitTest)
220+
221+
#if os(Windows)
222+
// For an explanation of this magic, see the corresponding logic in
223+
// ExitTest.callAsFunction().
224+
if case let .exitCode(exitCode) = result.exitCondition, (exitCode & ~STATUS_CODE_MASK) == STATUS_SIGNAL_CAUGHT_BITS {
225+
result.exitCondition = .signal(exitCode & STATUS_CODE_MASK)
226+
}
227+
#endif
204228
} catch {
205229
// An error here would indicate a problem in the exit test handler such as a
206230
// failure to find the process' path, to construct arguments to the

Sources/Testing/ExitTests/WaitFor.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,6 @@ func wait(for processHandle: consuming HANDLE) async throws -> ExitCondition {
260260
return .failure
261261
}
262262

263-
// FIXME: handle SEH/VEH uncaught exceptions.
264263
return .exitCode(CInt(bitPattern: .init(status)))
265264
}
266265
#else
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
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+
internal import _TestingInternals
12+
13+
#if os(Windows)
14+
/// A bitmask that can be applied to an `HRESULT` or `NTSTATUS` value to get the
15+
/// underlying status code.
16+
let STATUS_CODE_MASK = NTSTATUS(0xFFFF)
17+
18+
/// The severity and facility bits to mask against a caught signal value before
19+
/// terminating a child process.
20+
///
21+
/// For more information about the `NTSTATUS` type including its bitwise layout,
22+
/// see [Microsoft's documentation](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-erref/87fba13e-bf06-450e-83b1-9241dc81e781).
23+
let STATUS_SIGNAL_CAUGHT_BITS = {
24+
var result = NTSTATUS(0)
25+
26+
// Set the severity and status bits.
27+
result |= STATUS_SEVERITY_ERROR << 30
28+
result |= 1 << 29 // "Customer" bit
29+
30+
// We only have 12 facility bits, but we'll pretend they spell out "s6", short
31+
// for "Swift 6" of course.
32+
//
33+
// We're camping on a specific "facility" code here that we don't think is
34+
// otherwise in use; if it conflicts with an exit test, we can add an
35+
// environment variable lookup so callers can override us.
36+
let FACILITY_SWIFT6 = ((NTSTATUS(UInt8(ascii: "s")) << 4) | 6)
37+
result |= FACILITY_SWIFT6 << 16
38+
39+
#if DEBUG
40+
assert(
41+
(result & STATUS_CODE_MASK) == 0,
42+
"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"
43+
)
44+
#endif
45+
46+
return result
47+
}()
48+
#endif

Sources/_TestingInternals/include/Includes.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@
129129
#define WIN32_LEAN_AND_MEAN
130130
#define NOMINMAX
131131
#include <Windows.h>
132+
#include <ntstatus.h>
132133
#include <Psapi.h>
133134
#endif
134135

Tests/TestingTests/ExitTestTests.swift

Lines changed: 12 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -32,16 +32,20 @@ private import _TestingInternals
3232
await Task.yield()
3333
exit(123)
3434
}
35-
#if !os(Windows)
36-
await #expect(exitsWith: .signal(SIGKILL)) {
37-
_ = kill(getpid(), SIGKILL)
38-
// Allow up to 1s for the signal to be delivered.
39-
try! await Task.sleep(nanoseconds: 1_000_000_000_000)
35+
await #expect(exitsWith: .signal(SIGSEGV)) {
36+
_ = raise(SIGSEGV)
37+
// Allow up to 1s for the signal to be delivered. On some platforms,
38+
// raise() delivers signals fully asynchronously and may not terminate the
39+
// child process before this closure returns.
40+
if #available(_clockAPI, *) {
41+
try await Test.Clock.sleep(for: .seconds(1))
42+
} else {
43+
try await Task.sleep(nanoseconds: 1_000_000_000)
44+
}
4045
}
4146
await #expect(exitsWith: .signal(SIGABRT)) {
4247
abort()
4348
}
44-
#endif
4549
#if !SWT_NO_UNSTRUCTURED_TASKS
4650
#if false
4751
// Test the detached (no task-local configuration) path. Disabled because,
@@ -59,13 +63,7 @@ private import _TestingInternals
5963
}
6064

6165
@Test("Exit tests (failing)") func failing() async {
62-
let expectedCount: Int
63-
#if os(Windows)
64-
expectedCount = 6
65-
#else
66-
expectedCount = 10
67-
#endif
68-
await confirmation("Exit tests failed", expectedCount: expectedCount) { failed in
66+
await confirmation("Exit tests failed", expectedCount: 10) { failed in
6967
var configuration = Configuration()
7068
configuration.eventHandler = { event, _ in
7169
if case .issueRecorded = event.kind {
@@ -105,11 +103,9 @@ private import _TestingInternals
105103
await Test {
106104
await #expect(exitsWith: .exitCode(EXIT_FAILURE)) {}
107105
}.run(configuration: configuration)
108-
#if !os(Windows)
109106
await Test {
110107
await #expect(exitsWith: .signal(SIGABRT)) {}
111108
}.run(configuration: configuration)
112-
#endif
113109

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

122-
#if !os(Windows)
123118
// Mock an exit test where the process exits with a signal.
124119
configuration.exitTestHandler = { _ in
125120
return ExitTest.Result(exitCondition: .signal(SIGABRT))
@@ -130,18 +125,11 @@ private import _TestingInternals
130125
await Test {
131126
await #expect(exitsWith: .failure) {}
132127
}.run(configuration: configuration)
133-
#endif
134128
}
135129
}
136130

137131
@Test("Mock exit test handlers (failing)") func failingMockHandlers() async {
138-
let expectedCount: Int
139-
#if os(Windows)
140-
expectedCount = 2
141-
#else
142-
expectedCount = 6
143-
#endif
144-
await confirmation("Issue recorded", expectedCount: expectedCount) { issueRecorded in
132+
await confirmation("Issue recorded", expectedCount: 6) { issueRecorded in
145133
var configuration = Configuration()
146134
configuration.eventHandler = { event, _ in
147135
if case .issueRecorded = event.kind {
@@ -159,13 +147,10 @@ private import _TestingInternals
159147
await Test {
160148
await #expect(exitsWith: .exitCode(EXIT_FAILURE)) {}
161149
}.run(configuration: configuration)
162-
#if !os(Windows)
163150
await Test {
164151
await #expect(exitsWith: .signal(SIGABRT)) {}
165152
}.run(configuration: configuration)
166-
#endif
167153

168-
#if !os(Windows)
169154
// Mock exit tests that unexpectedly signalled.
170155
configuration.exitTestHandler = { _ in
171156
return ExitTest.Result(exitCondition: .signal(SIGABRT))
@@ -179,7 +164,6 @@ private import _TestingInternals
179164
await Test {
180165
await #expect(exitsWith: .success) {}
181166
}.run(configuration: configuration)
182-
#endif
183167
}
184168
}
185169

@@ -269,7 +253,6 @@ private import _TestingInternals
269253
#expect(ExitCondition.exitCode(EXIT_FAILURE &+ 1) != .exitCode(EXIT_FAILURE))
270254
#expect(ExitCondition.exitCode(EXIT_FAILURE &+ 1) !== .exitCode(EXIT_FAILURE))
271255

272-
#if !os(Windows)
273256
#expect(ExitCondition.success != .exitCode(EXIT_FAILURE))
274257
#expect(ExitCondition.success !== .exitCode(EXIT_FAILURE))
275258
#expect(ExitCondition.success != .signal(SIGINT))
@@ -278,7 +261,6 @@ private import _TestingInternals
278261
#expect(ExitCondition.signal(SIGINT) === .signal(SIGINT))
279262
#expect(ExitCondition.signal(SIGTERM) != .signal(SIGINT))
280263
#expect(ExitCondition.signal(SIGTERM) !== .signal(SIGINT))
281-
#endif
282264
}
283265

284266
@MainActor static func someMainActorFunction() {
@@ -415,7 +397,6 @@ private import _TestingInternals
415397
exit(0)
416398
}
417399

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

0 commit comments

Comments
 (0)