Skip to content

Commit c53a908

Browse files
committed
BridgeJS: Support throws(JSException) on generic exports
Generic exported functions and methods may now be throws(JSException), matching what generic imports already supported: @js public func pickOrThrow<T: BridgedSwiftGenericBridgeable>(_ value: T, _ fail: Bool) throws(JSException) -> T The concrete entry thunk wraps the existential-opening call chain in the same do/catch every throwing export uses — the exception crosses through the _swift_js_throw side channel — and the open-chain helpers become throws(JSException) so the typed error propagates without erasure. On the JavaScript side the wrapper rethrows the exception right after the wasm call and before lifting the result, so a thrown call reads nothing back and leaves the shared value stack balanced; the wrappers emitted through the thunk builder get this from the existing effects-driven exception check, and the struct-instance method path emits the same sequence explicitly. async generic exports remain rejected with a diagnostic. There is no compiler obstacle — the wasm32 typed-throws closure issues are already worked around by the forced-capture emission (swiftwasm#760) and JSException storage boxing (swiftwasm#766) — but promise settlement is per-type today: each async export settles through a Promise_resolve_<type> helper paired with a JS settle handler that lifts a concrete value. A generic result needs a codec-driven settlement path (stash the call's codec with the promise's settlers, settle through the value stack), which is its own ABI addition and lands separately. Runtime tests cover the happy path, the surfaced exception, and that a thrown call leaves the stacks balanced for the next generic call.
1 parent 484438c commit c53a908

15 files changed

Lines changed: 603 additions & 152 deletions

File tree

Plugins/BridgeJS/Sources/BridgeJSCore/ExportSwift.swift

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -628,7 +628,38 @@ public class ExportSwift {
628628
}
629629
}
630630
let open1Arguments = genericNames.map { metatypeName($0) } + concreteABINames
631-
printer.write("_\(abiName)_open1(\(open1Arguments.joined(separator: ", ")))")
631+
let open1Call = "_\(abiName)_open1(\(open1Arguments.joined(separator: ", ")))"
632+
if effects.isThrows {
633+
// Same throw-to-JS convention as concrete throwing exports:
634+
// the exception crosses through the side channel and the JS
635+
// wrapper rethrows it before lifting anything, so a thrown
636+
// call leaves the shared value stack balanced.
637+
printer.write("do {")
638+
printer.indent {
639+
printer.write("try \(open1Call)")
640+
}
641+
printer.write("} catch let error {")
642+
printer.indent {
643+
printer.write(
644+
multilineString: """
645+
if let error = error.thrownValue.object {
646+
withExtendedLifetime(error) {
647+
_swift_js_throw(Int32(bitPattern: $0.id))
648+
}
649+
} else {
650+
let jsError = JSError(message: error.description)
651+
withExtendedLifetime(jsError.jsObject) {
652+
_swift_js_throw(Int32(bitPattern: $0.id))
653+
}
654+
}
655+
\(returnPlaceholderStmt())
656+
"""
657+
)
658+
}
659+
printer.write("}")
660+
} else {
661+
printer.write(open1Call)
662+
}
632663
}
633664
printer.write(multilineString: entryDecl.description)
634665

@@ -654,7 +685,10 @@ public class ExportSwift {
654685
params.append("_ \(abiParam.name): \(abiParam.type.swiftType)")
655686
}
656687

657-
printer.write("private func \(openName)\(genericClause)(\(params.joined(separator: ", "))) {")
688+
let effectsClause = effects.isThrows ? " throws(JSException)" : ""
689+
printer.write(
690+
"private func \(openName)\(genericClause)(\(params.joined(separator: ", ")))\(effectsClause) {"
691+
)
658692
printer.indent {
659693
if k < count {
660694
let nextOpenedName = genericNames[k]
@@ -666,7 +700,8 @@ public class ExportSwift {
666700
callArguments.append(metatypeName(remainingName))
667701
}
668702
callArguments.append(contentsOf: concreteABINames)
669-
printer.write("_\(abiName)_open\(k + 1)(\(callArguments.joined(separator: ", ")))")
703+
let tryPrefix = effects.isThrows ? "try " : ""
704+
printer.write("\(tryPrefix)_\(abiName)_open\(k + 1)(\(callArguments.joined(separator: ", ")))")
670705
} else {
671706
// The standard builder-emitted body: `T` behaves like
672707
// any other bridged type via its protocol requirements.

Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1811,13 +1811,6 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
18111811
)
18121812
return nil
18131813
}
1814-
if node.signature.effectSpecifiers?.throwsClause != nil {
1815-
diagnose(
1816-
node: node,
1817-
message: "Generic @JS functions cannot be 'throws' yet."
1818-
)
1819-
return nil
1820-
}
18211814
}
18221815
let genericParameterNames = genericParameters.map(\.name)
18231816

Plugins/BridgeJS/Sources/BridgeJSLink/JSGlueGen.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,21 @@ enum GenericJSCodegen {
270270
/// combinators. Each container shape's stack ABI is described once here and
271271
/// instantiated with an element codec, instead of cloning the lowering and
272272
/// lifting logic per shape.
273+
/// The rethrow sequence a throwing export's wrapper runs right after the
274+
/// wasm call, mirroring `ExportedThunkBuilder.checkExceptionLines()` for
275+
/// glue emitted outside the thunk builder.
276+
static func checkExceptionLines() -> [String] {
277+
let exceptionVariable = JSGlueVariableScope.reservedStorageToReturnException
278+
return [
279+
"if (\(exceptionVariable)) {",
280+
" const error = \(JSGlueVariableScope.reservedSwift).memory.getObject(\(exceptionVariable));",
281+
" \(JSGlueVariableScope.reservedSwift).memory.release(\(exceptionVariable));",
282+
" \(exceptionVariable) = undefined;",
283+
" throw error;",
284+
"}",
285+
]
286+
}
287+
273288
static func runtimeHelperDeclarations() -> [String] {
274289
let codecByTypeId = JSGlueVariableScope.reservedCodecByTypeId
275290
return [
@@ -2714,6 +2729,11 @@ struct IntrinsicJSFragment: Sendable {
27142729
}
27152730
paramForwardings.append(contentsOf: method.genericParameterNames.compactMap { typeIdVariables[$0] })
27162731
printer.write("instance.exports.\(method.abiName)(\(paramForwardings.joined(separator: ", ")));")
2732+
if method.effects.isThrows {
2733+
// Rethrow before lifting: a thrown call pushed nothing onto the
2734+
// shared value stack, so the lift below must not run.
2735+
printer.write(lines: GenericJSCodegen.checkExceptionLines())
2736+
}
27172737
if let returnGenericName = method.returnType.referencedGenericName,
27182738
let codecVariable = codecVariables[returnGenericName],
27192739
let liftExpression = GenericJSCodegen.genericCodecLiftExpression(

Plugins/BridgeJS/Tests/BridgeJSToolTests/GenericExportDiagnosticsTests.swift

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -224,13 +224,15 @@ import Testing
224224
}
225225

226226
@Test
227-
func throwsGenericExportUnsupported() {
228-
expectDiagnostic(
229-
source: """
230-
@JS public func f<T: BridgedSwiftGenericBridgeable>(_ v: T) throws(JSException) -> T { v }
231-
""",
232-
contains: "Generic @JS functions cannot be 'throws' yet."
227+
func throwsGenericExportIsAccepted() throws {
228+
let skeleton = try makeSkeleton(
229+
"""
230+
@JS public func f<T: BridgedSwiftGenericBridgeable>(_ v: T) throws(JSException) -> T { v }
231+
"""
233232
)
233+
let function = try #require(skeleton.exported?.functions.first)
234+
#expect(function.effects.isThrows)
235+
#expect(function.genericParameterNames == ["T"])
234236
}
235237

236238
@Test(arguments: [
@@ -293,16 +295,18 @@ import Testing
293295
}
294296

295297
@Test
296-
func genericInstanceMethodThrowsIsRejected() {
297-
expectDiagnostic(
298-
source: """
299-
@JS class Box {
300-
@JS init() {}
301-
@JS func wrap<T: BridgedSwiftGenericBridgeable>(_ v: T) throws(JSException) -> T { v }
302-
}
303-
""",
304-
contains: "Generic @JS functions cannot be 'throws' yet."
298+
func genericInstanceMethodThrowsIsAccepted() throws {
299+
let skeleton = try makeSkeleton(
300+
"""
301+
@JS class Box {
302+
@JS init() {}
303+
@JS func wrap<T: BridgedSwiftGenericBridgeable>(_ v: T) throws(JSException) -> T { v }
304+
}
305+
"""
305306
)
307+
let method = try #require(skeleton.exported?.classes.first?.methods.first)
308+
#expect(method.effects.isThrows)
309+
#expect(method.genericParameterNames == ["T"])
306310
}
307311

308312
@Test

Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/GenericExports.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,29 @@ public func genericExportCaseDistinct<
160160
}
161161
}
162162

163+
// Throwing generic exports: the exception crosses through the side channel and
164+
// the JS wrapper rethrows it before lifting anything from the value stack.
165+
@JS public func genericPickOrThrow<T: BridgedSwiftGenericBridgeable>(
166+
_ value: T,
167+
_ shouldThrow: Bool
168+
) throws(JSException) -> T {
169+
if shouldThrow {
170+
throw JSException(JSError(message: "generic pick failed").jsValue)
171+
}
172+
return value
173+
}
174+
175+
@JS struct GenericThrowingBox {
176+
@JS init() {}
177+
178+
@JS func reject<T: BridgedSwiftGenericBridgeable>(_ value: T) throws(JSException) -> T {
179+
throw JSException(JSError(message: "boxed rejection").jsValue)
180+
}
181+
182+
// A renamed generic struct method must be attached under its JS name, the
183+
// same name the d.ts declares.
184+
@JS("passThrough") func forward<T: BridgedSwiftGenericBridgeable>(_ value: T) -> T {
185+
value
163186
// A namespaced final class: its token is `ExportGenericNamespace_Handle`, but
164187
// its d.ts interface is emitted at the top level, so the token table must not
165188
// spell the type as `ExportGenericNamespace.Handle`.

Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/GenericExports.json

Lines changed: 120 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -234,31 +234,6 @@
234234

235235
],
236236
"swiftCallName" : "GenericBox"
237-
},
238-
{
239-
"constructor" : {
240-
"abiName" : "bjs_ExportGenericNamespace_Handle_init",
241-
"effects" : {
242-
"isAsync" : false,
243-
"isStatic" : false,
244-
"isThrows" : false
245-
},
246-
"parameters" : [
247-
248-
]
249-
},
250-
"isFinal" : true,
251-
"methods" : [
252-
253-
],
254-
"name" : "Handle",
255-
"namespace" : [
256-
"ExportGenericNamespace"
257-
],
258-
"properties" : [
259-
260-
],
261-
"swiftCallName" : "ExportGenericNamespace.Handle"
262237
}
263238
],
264239
"enums" : [
@@ -510,21 +485,6 @@
510485
],
511486
"swiftCallName" : "GenericNamespace",
512487
"tsFullPath" : "GenericNamespace"
513-
},
514-
{
515-
"cases" : [
516-
517-
],
518-
"emitStyle" : "const",
519-
"name" : "ExportGenericNamespace",
520-
"staticMethods" : [
521-
522-
],
523-
"staticProperties" : [
524-
525-
],
526-
"swiftCallName" : "ExportGenericNamespace",
527-
"tsFullPath" : "ExportGenericNamespace"
528488
}
529489
],
530490
"exposeToGlobal" : false,
@@ -1045,6 +1005,45 @@
10451005
"_0" : "T"
10461006
}
10471007
}
1008+
},
1009+
{
1010+
"abiName" : "bjs_genericPickOrThrow",
1011+
"effects" : {
1012+
"isAsync" : false,
1013+
"isStatic" : false,
1014+
"isThrows" : true
1015+
},
1016+
"genericParameters" : [
1017+
{
1018+
"name" : "T"
1019+
}
1020+
],
1021+
"name" : "genericPickOrThrow",
1022+
"parameters" : [
1023+
{
1024+
"label" : "_",
1025+
"name" : "value",
1026+
"type" : {
1027+
"generic" : {
1028+
"_0" : "T"
1029+
}
1030+
}
1031+
},
1032+
{
1033+
"label" : "_",
1034+
"name" : "shouldThrow",
1035+
"type" : {
1036+
"bool" : {
1037+
1038+
}
1039+
}
1040+
}
1041+
],
1042+
"returnType" : {
1043+
"generic" : {
1044+
"_0" : "T"
1045+
}
1046+
}
10481047
}
10491048
],
10501049
"protocols" : [
@@ -1326,6 +1325,87 @@
13261325

13271326
],
13281327
"swiftCallName" : "GenericPair"
1328+
},
1329+
{
1330+
"constructor" : {
1331+
"abiName" : "bjs_GenericThrowingBox_init",
1332+
"effects" : {
1333+
"isAsync" : false,
1334+
"isStatic" : false,
1335+
"isThrows" : false
1336+
},
1337+
"parameters" : [
1338+
1339+
]
1340+
},
1341+
"methods" : [
1342+
{
1343+
"abiName" : "bjs_GenericThrowingBox_reject",
1344+
"effects" : {
1345+
"isAsync" : false,
1346+
"isStatic" : false,
1347+
"isThrows" : true
1348+
},
1349+
"genericParameters" : [
1350+
{
1351+
"name" : "T"
1352+
}
1353+
],
1354+
"name" : "reject",
1355+
"parameters" : [
1356+
{
1357+
"label" : "_",
1358+
"name" : "value",
1359+
"type" : {
1360+
"generic" : {
1361+
"_0" : "T"
1362+
}
1363+
}
1364+
}
1365+
],
1366+
"returnType" : {
1367+
"generic" : {
1368+
"_0" : "T"
1369+
}
1370+
}
1371+
},
1372+
{
1373+
"abiName" : "bjs_GenericThrowingBox_passThrough",
1374+
"effects" : {
1375+
"isAsync" : false,
1376+
"isStatic" : false,
1377+
"isThrows" : false
1378+
},
1379+
"genericParameters" : [
1380+
{
1381+
"name" : "T"
1382+
}
1383+
],
1384+
"jsName" : "passThrough",
1385+
"name" : "forward",
1386+
"parameters" : [
1387+
{
1388+
"label" : "_",
1389+
"name" : "value",
1390+
"type" : {
1391+
"generic" : {
1392+
"_0" : "T"
1393+
}
1394+
}
1395+
}
1396+
],
1397+
"returnType" : {
1398+
"generic" : {
1399+
"_0" : "T"
1400+
}
1401+
}
1402+
}
1403+
],
1404+
"name" : "GenericThrowingBox",
1405+
"properties" : [
1406+
1407+
],
1408+
"swiftCallName" : "GenericThrowingBox"
13291409
}
13301410
]
13311411
},

0 commit comments

Comments
 (0)