Skip to content

Commit 04df82a

Browse files
committed
[TWG] Exit test value capturing proposal
Add the initial draft of ST-NNNN Capturing values in exit tests.
1 parent 447f4a1 commit 04df82a

File tree

1 file changed

+264
-0
lines changed

1 file changed

+264
-0
lines changed
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
# Capturing values in exit tests
2+
3+
* Proposal: [ST-NNNN](NNNN-filename.md)
4+
* Authors: [Jonathan Grynspan](https://github.com/grynspan)
5+
* Review Manager: TBD
6+
* Status: **Awaiting review**
7+
* Bug: [swiftlang/swift-testing#1157](https://github.com/swiftlang/swift-testing/issues/1157)
8+
* Implementation: [swiftlang/swift-testing#1040](https://github.com/swiftlang/swift-testing/pull/1040) _et al._
9+
* Review: ([pitch](https://forums.swift.org/t/pitch-capturing-values-in-exit-tests/80494))
10+
11+
## Introduction
12+
13+
In Swift 6.2, we introduced the concept of an _exit test_: a section of code in
14+
a test function that would run in an independent process and allow test authors
15+
to test code that terminates the process. For example:
16+
17+
```swift
18+
enum Fruit: Equatable {
19+
case apple, orange, olive, tomato
20+
var isSweet: Bool { get }
21+
22+
consuming func feed(to bat: FruitBat) {
23+
precondition(self.isSweet, "Fruit bats don't like savory fruits!")
24+
...
25+
}
26+
}
27+
28+
@Test func `Fruit bats don't eat savory fruits`() async {
29+
await #expect(processExitsWith: .failure) {
30+
let fruit = Fruit.olive
31+
let bat = FruitBat(named: "Chauncey")
32+
fruit.feed(to: bat) // should trigger a precondition failure and process termination
33+
}
34+
}
35+
```
36+
37+
This proposal extends exit tests to support capturing state from the enclosing
38+
context (subject to several practical constraints.)
39+
40+
## Motivation
41+
42+
Exit tests in their current form are useful, but there is no reliable way to
43+
pass non-constant information from the parent process to the child process,
44+
which makes them difficult to use with parameterized tests. Consider:
45+
46+
```swift
47+
@Test(arguments: [Fruit.olive, .tomato])
48+
func `Fruit bats don't eat savory fruits`(_ fruit: Fruit) async {
49+
await #expect(processExitsWith: .failure) {
50+
let bat = FruitBat(named: "Chauncey")
51+
fruit.feed(to: bat) // 🛑 can't capture 'fruit' from enclosing scope
52+
}
53+
}
54+
```
55+
56+
In the above example, the test function's argument cannot be passed into the
57+
exit test. In a trivial example like this one, it wouldn't be difficult to write
58+
two tests that differ only in the case of `Fruit` they use in their exit test
59+
bodies, but this approach doesn't scale very far and is generally an
60+
anti-pattern when using Swift Testing.
61+
62+
## Proposed solution
63+
64+
We propose allowing the capture of values in an exit test when they are
65+
specified in a closure capture list on the exit test's body.
66+
67+
## Detailed design
68+
69+
The signatures of the exit test macros `expect(processExitsWith:)` and
70+
`require(processExitsWith:)` are unchanged. A test author may now add a closure
71+
capture list to the body of an exit test:
72+
73+
```swift
74+
@Test(arguments: [Fruit.olive, .tomato])
75+
func `Fruit bats don't eat savory fruits`(_ fruit: Fruit) async {
76+
await #expect(processExitsWith: .failure) { [fruit] in
77+
let bat = FruitBat(named: "Chauncey")
78+
fruit.feed(to: bat)
79+
}
80+
}
81+
```
82+
83+
This feature has some necessary basic constraints:
84+
85+
### Captured values must be explicitly listed in a closure capture list
86+
87+
Swift Testing needs to know what values need to be encoded, sent to the child
88+
process, and decoded. Swift macros including `#expect(processExitsWith:)` must
89+
rely solely on syntax—that is, the code typed by a test author. An implicit
90+
capture within an exit test body is indistinguishable from any other identifier
91+
or symbol name.
92+
93+
Hence, only values listed in the closure's capture list will be captured.
94+
Implicitly captured values will produce a compile-time diagnostic as they do
95+
today.
96+
97+
### Captured values must conform to Sendable and Codable
98+
99+
Captured values will be sent across process boundaries and, in order to support
100+
that operation, must conform to `Codable`. As well, captured values need to make
101+
their way through the various internal mechanisms of Swift Testing and its host
102+
infrastructure, and so must conform to `Sendable`. Conformance to `Copyable` and
103+
`Escapable` is implied.
104+
105+
If a value that does _not_ conform to the above protocols is specified in an
106+
exit test body's capture list, a diagnostic is emitted:
107+
108+
```swift
109+
let bat: FruitBat = ...
110+
await #expect(processExitsWith: .failure) { [bat] in
111+
// 🛑 Type of captured value 'bat' must conform to 'Sendable' and 'Codable'
112+
...
113+
}
114+
```
115+
116+
### Captured values' types must be visible to the exit test macro
117+
118+
In order for us to successfully _decode_ captured values in the child process,
119+
we must know their Swift types. Type information is not readily available during
120+
macro expansion and we must, in general, rely on the parsed syntax tree for it.
121+
122+
The type of `self` and the types of arguments to the calling function are,
123+
generally, known and can be inferred from context[^shadows]. The types of other
124+
values, including local variables and global state, are not visible in the
125+
syntax tree and must be specified explicitly in the capture list using an `as`
126+
expression:
127+
128+
```swift
129+
await #expect(processExitsWith: .failure) { [fruit = fruit as Fruit] in
130+
...
131+
}
132+
```
133+
134+
Finally, the types of captured literals (e.g. `[x = 123]`) are known at compile
135+
time and can always be inferred as `IntegerLiteralType` etc., although we don't
136+
anticipate this will be particularly useful in practice.
137+
138+
If the type of a captured value cannot be resolved from context, the test author
139+
will see an error at compile time:
140+
141+
```swift
142+
await #expect(processExitsWith: .failure) { [fruit] in
143+
// 🛑 Type of captured value 'fruit' is ambiguous
144+
// Fix-It: Add '= fruit as T'
145+
...
146+
}
147+
```
148+
149+
See the **Future directions** section of this proposal for more information on
150+
how we hope to lift this constraint. If we are able to lift this constraint in
151+
the future, we expect it will not require (no pun intended) a second Swift
152+
Evolution proposal.
153+
154+
[^shadows]: If a local variable is declared that shadows `self` or a function
155+
argument, we may incorrectly infer the type of that value when captured. When
156+
this occurs, Swift Testing emits a diagnostic of the form "🛑 Type of captured
157+
value 'foo' is ambiguous".
158+
159+
## Source compatibility
160+
161+
This change is additive and relies on syntax that would previously be rejected
162+
at compile time.
163+
164+
## Integration with supporting tools
165+
166+
Xcode, Swift Package Manager, and the Swift VS Code plugin _already_ support
167+
captured values in exit tests as they use Swift Testing's built-in exit test
168+
handling logic.
169+
170+
Tools that implement their own exit test handling logic will need to account for
171+
captured values. The `ExitTest` type now has a new SPI property:
172+
173+
```swift
174+
extension ExitTest {
175+
/// The set of values captured in the parent process before the exit test is
176+
/// called.
177+
///
178+
/// This property is automatically set by the testing library when using the
179+
/// built-in exit test handler and entry point functions. Do not modify the
180+
/// value of this property unless you are implementing a custom exit test
181+
/// handler or entry point function.
182+
///
183+
/// The order of values in this array must be the same between the parent and
184+
/// child processes.
185+
@_spi(ForToolsIntegrationOnly)
186+
public var capturedValues: [CapturedValue] { get set }
187+
}
188+
```
189+
190+
In the parent process (that is, for an instance of `ExitTest` passed to
191+
`Configuration.exitTestHandler`), this property represents the values captured
192+
at runtime by the exit test. In the child process (that is, for an instance of
193+
`ExitTest` returned from `ExitTest.find(identifiedBy:)`), the elements in this
194+
array do not have values associated with them until the hosting tool provides
195+
them.
196+
197+
## Future directions
198+
199+
- Supporting captured values without requiring type information
200+
201+
We need the types of captured values in order to successfully decode them, but
202+
we are constrained by macros being syntax-only. In the future, the compiler
203+
may gain a language feature similar to `decltype()` in C++ or `typeof()` in
204+
C23, in which case we should be able to use it and avoid the need for explicit
205+
types in the capture list. ([rdar://153389205](rdar://153389205))
206+
207+
- Supporting capturing values that do not conform to `Codable`
208+
209+
Alternatives to `Codable` exist or have been proposed, such as
210+
[`NSSecureCoding`](https://developer.apple.com/documentation/foundation/nssecurecoding)
211+
or [`JSONCodable`](https://forums.swift.org/t/the-future-of-serialization-deserialization-apis/78585).
212+
In the future, we may want to extend support for values that conform to these
213+
protocols instead of `Codable`.
214+
215+
## Alternatives considered
216+
217+
- Doing nothing. There is sufficient motivation to support capturing values in
218+
exit tests and it is within our technical capabilities.
219+
220+
- Passing captured values as arguments to `#expect(processExitsWith:)` and its
221+
body closure. For example:
222+
223+
```swift
224+
await #expect(
225+
processExitsWith: .failure,
226+
arguments: [fruit, bat]
227+
) { fruit, bat in
228+
...
229+
}
230+
```
231+
232+
This is technically feasible, but:
233+
234+
- It requires that the caller state the capture list twice;
235+
- Type information still isn't available for captured values, so you'd still
236+
need to _actually_ write `{ (fruit: Fruit, bat: Bat) in ... }` (or otherwise
237+
specify the types somewhere in the macro invocation); and
238+
- The language already has a dedicated syntax for specifying lists of values
239+
that should be captured in a closure.
240+
241+
- Supporting non-`Sendable` or non-`Codable` captured values. Since exit tests'
242+
bodies are, by definition, in separate isolation domains from the caller, and
243+
since they, by nature, run in separate processes, conformance to these
244+
protocols is fundamentally necessary.
245+
246+
- Implicitly capturing `self`. This would require us to statically detect during
247+
macro expansion whether `self` conformed to the necessary protocols _and_
248+
would preclude capturing any state from static or free test functions.
249+
250+
- Forking the exit test process such that all captured values are implicitly
251+
copied by the kernel into the new process. Forking, in the UNIX fashion, is
252+
fundamentally incompatible with the Swift runtime and the Swift thread pool.
253+
On Darwin, you [cannot fork a process that links to Core Foundation without
254+
immediately calling `exec()`](https://duckduckgo.com/?q=__THE_PROCESS_HAS_FORKED_AND_YOU_CANNOT_USE_THIS_COREFOUNDATION_FUNCTIONALITY___YOU_MUST_EXEC__),
255+
and `fork()` isn't even present on Windows.
256+
257+
## Acknowledgments
258+
259+
Thanks to @rintaro for assistance investigating swift-syntax diagnostic support
260+
and to @xedin for humouring my questions about `decltype()`.
261+
262+
Thanks to the Swift Testing team and the Testing Workgroup as always. And thanks
263+
to those individuals, who shall remain unnamed, who nerd-sniped me into building
264+
this feature.

0 commit comments

Comments
 (0)