Skip to content

Commit 24e91ea

Browse files
committed
Tests: Add reproducer for Swift 6.3 typed-throws async closure miscompile
1 parent dad4c01 commit 24e91ea

1 file changed

Lines changed: 244 additions & 0 deletions

File tree

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
import XCTest
2+
import JavaScriptKit
3+
import JavaScriptEventLoop
4+
5+
// =============================================================================
6+
// REPRODUCER: Swift 6.3 wasm32 typed-throws + async + closure-value miscompile
7+
// =============================================================================
8+
//
9+
// Toolchain / SDK: Swift 6.3 (`swift-6.3-RELEASE`), WebAssembly SDK
10+
// `swift-6.3-RELEASE_wasm` (target `wasm32-unknown-wasip1`).
11+
// Run with: make unittest SWIFT_SDK_ID=swift-6.3-RELEASE_wasm
12+
//
13+
// ----------------------------------------------------------------------------
14+
// WHAT THE BUG IS
15+
// ----------------------------------------------------------------------------
16+
// When an error is thrown from a *typed-throws* (`throws(ConcreteError)`)
17+
// `async` *closure VALUE*, the thrown error's payload can be corrupted as it
18+
// propagates back across the async unwind. The equivalent `async` *function*
19+
// (same signature, same error) preserves the payload, and so does an *untyped*
20+
// (`async throws`) closure value. The corruption is specific to the
21+
// combination: typed-throws + async + closure value.
22+
//
23+
// ----------------------------------------------------------------------------
24+
// WHICH PAYLOAD VARIANTS REPRODUCE (measured on swift-6.3-RELEASE_wasm)
25+
// ----------------------------------------------------------------------------
26+
// Three payload kinds were exercised through three call shapes each
27+
// (async function / typed-throws async closure value / untyped async closure):
28+
//
29+
// payload kind function typed closure untyped closure
30+
// ------------------------------ -------- ------------- ---------------
31+
// plain value error (struct) ALIVE ALIVE ALIVE
32+
// reference error (class box) ALIVE ALIVE (n/a)
33+
// JSException (JSValue) object!=nil object==nil* object!=nil
34+
//
35+
// * = THE BUG. The JSValue-backed payload is destroyed (its `.object`
36+
// becomes nil) only in the typed-throws async *closure value* case.
37+
//
38+
// So this is NOT a general typed-throws-async-closure miscompile: ordinary
39+
// value- and reference-typed Swift error payloads survive intact in every
40+
// shape. It reproduces specifically when the error payload carries a
41+
// `JSValue` (here via JavaScriptKit's `JSException`, whose payload is a
42+
// reference to a JS object represented by an opaque ABI id). That id is lost
43+
// across the typed-throws async closure unwind, so on the catching side
44+
// `thrownValue.object` is nil and the original JS error object (its message,
45+
// stack, etc.) is gone.
46+
//
47+
// A second, related fragility was observed while building this reproducer:
48+
// packing all of the closure variants into a single large `async` test
49+
// function made the Swift 6.3 compiler crash in `SILGenCleanup`
50+
// ("fatal error encountered during compilation"). Keeping each shape in its
51+
// own method avoids that crash; the runtime payload-loss above is independent
52+
// of it.
53+
//
54+
// ----------------------------------------------------------------------------
55+
// HOW THE BUG IS ENCODED IN THIS TEST
56+
// ----------------------------------------------------------------------------
57+
// `XCTExpectFailure` / `withKnownIssue` are NOT available in the
58+
// swift-corelibs-xctest build shipped in the wasm SDK, so the buggy case is
59+
// asserted against its CURRENT (incorrect) observed value, clearly marked
60+
// below. The control cases (function, untyped closure) assert the CORRECT
61+
// behavior and pass normally.
62+
//
63+
// >>> WHEN THE COMPILER IS FIXED, `testBug_typedThrowsAsyncClosure_*` will
64+
// >>> start FAILING. Flip the marked assertion from the buggy value to the
65+
// >>> correct one (payload preserved) at that point.
66+
// =============================================================================
67+
68+
// MARK: - Payload types
69+
70+
/// Plain value-type Swift error. Does NOT reproduce the bug (payload survives).
71+
struct PlainValuePayloadError: Error, Equatable {
72+
let marker: String
73+
}
74+
75+
/// Reference-type Swift error payload. Does NOT reproduce the bug (survives).
76+
final class MarkerBox: @unchecked Sendable {
77+
let marker: String
78+
init(_ marker: String) { self.marker = marker }
79+
}
80+
struct ReferencePayloadError: Error {
81+
let box: MarkerBox
82+
}
83+
84+
// MARK: - Control "async function" forms (payload must survive)
85+
86+
private func asyncFunctionThrowsValue() async throws(PlainValuePayloadError) {
87+
throw PlainValuePayloadError(marker: "ALIVE")
88+
}
89+
90+
private func asyncFunctionThrowsReference() async throws(ReferencePayloadError) {
91+
throw ReferencePayloadError(box: MarkerBox("ALIVE"))
92+
}
93+
94+
private func asyncFunctionThrowsJSException() async throws(JSException) {
95+
throw JSException(JSError(message: "ALIVE").jsValue)
96+
}
97+
98+
final class TypedThrowsAsyncClosureBugTests: XCTestCase {
99+
100+
// -------------------------------------------------------------------------
101+
// (a) Plain VALUE-type payload -> does NOT reproduce; all forms preserve.
102+
// -------------------------------------------------------------------------
103+
104+
func testControl_valuePayload_asyncFunction() async throws {
105+
do {
106+
try await asyncFunctionThrowsValue()
107+
XCTFail("expected throw")
108+
} catch let error as PlainValuePayloadError {
109+
print("[REPRO] value / async function payload: \(error.marker)")
110+
XCTAssertEqual(error.marker, "ALIVE")
111+
}
112+
}
113+
114+
func testControl_valuePayload_typedThrowsAsyncClosure() async throws {
115+
let closure: () async throws(PlainValuePayloadError) -> Void = { () async throws(PlainValuePayloadError) -> Void in
116+
throw PlainValuePayloadError(marker: "ALIVE")
117+
}
118+
do {
119+
try await closure()
120+
XCTFail("expected throw")
121+
} catch let error as PlainValuePayloadError {
122+
print("[REPRO] value / typed-throws async closure payload: \(error.marker)")
123+
// Value payloads survive even in the buggy shape.
124+
XCTAssertEqual(error.marker, "ALIVE")
125+
}
126+
}
127+
128+
func testControl_valuePayload_untypedAsyncClosure() async throws {
129+
let closure: () async throws -> Void = {
130+
throw PlainValuePayloadError(marker: "ALIVE")
131+
}
132+
do {
133+
try await closure()
134+
XCTFail("expected throw")
135+
} catch let error as PlainValuePayloadError {
136+
print("[REPRO] value / untyped async closure payload: \(error.marker)")
137+
XCTAssertEqual(error.marker, "ALIVE")
138+
}
139+
}
140+
141+
// -------------------------------------------------------------------------
142+
// (a2) REFERENCE-type payload -> does NOT reproduce; payload preserved.
143+
// -------------------------------------------------------------------------
144+
145+
func testControl_referencePayload_asyncFunction() async throws {
146+
do {
147+
try await asyncFunctionThrowsReference()
148+
XCTFail("expected throw")
149+
} catch let error as ReferencePayloadError {
150+
print("[REPRO] ref / async function payload: \(error.box.marker)")
151+
XCTAssertEqual(error.box.marker, "ALIVE")
152+
}
153+
}
154+
155+
func testControl_referencePayload_typedThrowsAsyncClosure() async throws {
156+
let closure: () async throws(ReferencePayloadError) -> Void = { () async throws(ReferencePayloadError) -> Void in
157+
throw ReferencePayloadError(box: MarkerBox("ALIVE"))
158+
}
159+
do {
160+
try await closure()
161+
XCTFail("expected throw")
162+
} catch let error as ReferencePayloadError {
163+
print("[REPRO] ref / typed-throws async closure payload: \(error.box.marker)")
164+
// Reference payloads also survive in the buggy shape.
165+
XCTAssertEqual(error.box.marker, "ALIVE")
166+
}
167+
}
168+
169+
// -------------------------------------------------------------------------
170+
// (b) JSException (JSValue) payload -> REPRODUCES the bug.
171+
//
172+
// Control: async function preserves the thrown JS object.
173+
// Control: untyped async closure preserves the thrown JS object.
174+
// Bug: typed-throws async closure DESTROYS the thrown JS object.
175+
// -------------------------------------------------------------------------
176+
177+
func testControl_jsException_asyncFunction() async throws {
178+
do {
179+
try await asyncFunctionThrowsJSException()
180+
XCTFail("expected throw")
181+
} catch let error as JSException {
182+
let alive = error.thrownValue.object != nil
183+
print("[REPRO] jsexc / async function object!=nil: \(alive)")
184+
XCTAssertTrue(alive, "async function should preserve the thrown JS object")
185+
XCTAssertEqual(error.thrownValue.object?.message.string, "ALIVE")
186+
}
187+
}
188+
189+
func testControl_jsException_untypedAsyncClosure() async throws {
190+
let closure: () async throws -> Void = {
191+
throw JSException(JSError(message: "ALIVE").jsValue)
192+
}
193+
do {
194+
try await closure()
195+
XCTFail("expected throw")
196+
} catch let error as JSException {
197+
let alive = error.thrownValue.object != nil
198+
print("[REPRO] jsexc / untyped async closure object!=nil: \(alive)")
199+
XCTAssertTrue(alive, "untyped async closure should preserve the thrown JS object")
200+
XCTAssertEqual(error.thrownValue.object?.message.string, "ALIVE")
201+
} catch {
202+
XCTFail("unexpected error type: \(error)")
203+
}
204+
}
205+
206+
/// THE BUG. A `() async throws(JSException) -> Void` closure VALUE throws a
207+
/// `JSException` wrapping a live JS `Error` object. After the async unwind,
208+
/// the caught exception's `thrownValue.object` is nil: the JS object payload
209+
/// has been destroyed. Compare with the two controls above, which preserve
210+
/// it for the exact same `JSException`.
211+
///
212+
/// The assertion below encodes the CURRENT (buggy) behavior so the suite
213+
/// stays green. `XCTExpectFailure` is unavailable in the wasm XCTest
214+
/// runner, so this is a deliberate "known bug" assertion.
215+
///
216+
/// >>> WHEN FIXED: this test will fail. Replace the buggy assertions with:
217+
/// >>> XCTAssertTrue(alive)
218+
/// >>> XCTAssertEqual(error.thrownValue.object?.message.string, "ALIVE")
219+
func testBug_jsException_typedThrowsAsyncClosure() async throws {
220+
let closure: () async throws(JSException) -> Void = { () async throws(JSException) -> Void in
221+
throw JSException(JSError(message: "ALIVE").jsValue)
222+
}
223+
do {
224+
try await closure()
225+
XCTFail("expected throw")
226+
} catch let error as JSException {
227+
let alive = error.thrownValue.object != nil
228+
print("[REPRO] jsexc / typed-throws async closure object!=nil: \(alive) <-- BUG: payload lost")
229+
230+
// KNOWN-BUG ASSERTION (Swift 6.3 wasm32). The payload is destroyed,
231+
// so `.object` is nil here. This documents the miscompile; flip to
232+
// `XCTAssertTrue(alive)` once the compiler is fixed.
233+
XCTAssertFalse(
234+
alive,
235+
"""
236+
Swift 6.3 wasm32 typed-throws async closure value is expected to LOSE \
237+
the JSException payload. If this assertion now fails, the compiler bug \
238+
appears fixed -- flip this to XCTAssertTrue(alive) and re-enable the \
239+
message round-trip check.
240+
"""
241+
)
242+
}
243+
}
244+
}

0 commit comments

Comments
 (0)