Skip to content

Commit ff403eb

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 e04f4e0 commit ff403eb

16 files changed

Lines changed: 617 additions & 36 deletions

File tree

‎Plugins/BridgeJS/Sources/BridgeJSCore/ExportSwift.swift‎

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

@@ -657,7 +688,10 @@ public class ExportSwift {
657688
params.append("_ \(abiParam.name): \(abiParam.type.swiftType)")
658689
}
659690

660-
printer.write("private func \(openName)\(genericClause)(\(params.joined(separator: ", "))) {")
691+
let effectsClause = effects.isThrows ? " throws(JSException)" : ""
692+
printer.write(
693+
"private func \(openName)\(genericClause)(\(params.joined(separator: ", ")))\(effectsClause) {"
694+
)
661695
printer.indent {
662696
if k < count {
663697
let nextOpenedName = genericNames[k]
@@ -669,7 +703,8 @@ public class ExportSwift {
669703
callArguments.append(metatypeName(remainingName))
670704
}
671705
callArguments.append(contentsOf: concreteABINames)
672-
printer.write("_\(abiName)_open\(k + 1)(\(callArguments.joined(separator: ", ")))")
706+
let tryPrefix = effects.isThrows ? "try " : ""
707+
printer.write("\(tryPrefix)_\(abiName)_open\(k + 1)(\(callArguments.joined(separator: ", ")))")
673708
} else {
674709
// The standard builder-emitted body: `T` behaves like
675710
// 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
@@ -1767,13 +1767,6 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
17671767
)
17681768
return nil
17691769
}
1770-
if node.signature.effectSpecifiers?.throwsClause != nil {
1771-
diagnose(
1772-
node: node,
1773-
message: "Generic @JS functions cannot be 'throws' yet."
1774-
)
1775-
return nil
1776-
}
17771770
}
17781771

17791772
let genericParameterNames = genericParameters.map(\.name)

‎Plugins/BridgeJS/Sources/BridgeJSLink/JSGlueGen.swift‎

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,21 @@ enum GenericJSCodegen {
274274
]
275275
}
276276

277+
/// The rethrow sequence a throwing export's wrapper runs right after the
278+
/// wasm call, mirroring `ExportedThunkBuilder.checkExceptionLines()` for
279+
/// glue emitted outside the thunk builder.
280+
static func checkExceptionLines() -> [String] {
281+
let exceptionVariable = JSGlueVariableScope.reservedStorageToReturnException
282+
return [
283+
"if (\(exceptionVariable)) {",
284+
" const error = \(JSGlueVariableScope.reservedSwift).memory.getObject(\(exceptionVariable));",
285+
" \(JSGlueVariableScope.reservedSwift).memory.release(\(exceptionVariable));",
286+
" \(exceptionVariable) = undefined;",
287+
" throw error;",
288+
"}",
289+
]
290+
}
291+
277292
/// Shared generic runtime: a type-ID-keyed codec table plus codec
278293
/// combinators. Each container shape's stack ABI is described once here and
279294
/// instantiated with an element codec, instead of cloning the lowering and
@@ -2716,15 +2731,19 @@ struct IntrinsicJSFragment: Sendable {
27162731
var paramForwardings: [String] = []
27172732
for param in method.parameters {
27182733
if let genericName = param.type.referencedGenericName {
2719-
if let codecVariable = codecVariables[genericName],
2734+
guard let codecVariable = codecVariables[genericName],
27202735
let lowerStatement = GenericJSCodegen.genericCodecLowerStatement(
27212736
type: param.type,
27222737
codec: codecVariable,
27232738
value: param.name
27242739
)
2725-
{
2726-
printer.write(lowerStatement)
2740+
else {
2741+
throw BridgeJSLinkError(
2742+
message:
2743+
"Generic codec for '\(genericName)' was not declared before lowering parameter '\(param.name)'"
2744+
)
27272745
}
2746+
printer.write(lowerStatement)
27282747
} else {
27292748
let fragment = try IntrinsicJSFragment.lowerParameter(type: param.type)
27302749
let loweredValues = try fragment.printCode([param.name], methodContext)
@@ -2733,6 +2752,11 @@ struct IntrinsicJSFragment: Sendable {
27332752
}
27342753
paramForwardings.append(contentsOf: method.genericParameterNames.compactMap { typeIdVariables[$0] })
27352754
printer.write("instance.exports.\(method.abiName)(\(paramForwardings.joined(separator: ", ")));")
2755+
if method.effects.isThrows {
2756+
// Rethrow before lifting: a thrown call pushed nothing onto the
2757+
// shared value stack, so the lift below must not run.
2758+
printer.write(lines: GenericJSCodegen.checkExceptionLines())
2759+
}
27362760
if let returnGenericName = method.returnType.referencedGenericName,
27372761
let codecVariable = codecVariables[returnGenericName],
27382762
let liftExpression = GenericJSCodegen.genericCodecLiftExpression(

‎Plugins/BridgeJS/Tests/BridgeJSToolTests/GenericExportDiagnosticsTests.swift‎

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -238,13 +238,15 @@ import Testing
238238
}
239239

240240
@Test
241-
func throwsGenericExportUnsupported() {
242-
expectDiagnostic(
243-
source: """
244-
@JS public func f<T: BridgedSwiftGenericBridgeable>(_ v: T) throws(JSException) -> T { v }
245-
""",
246-
contains: "Generic @JS functions cannot be 'throws' yet."
241+
func throwsGenericExportIsAccepted() throws {
242+
let skeleton = try makeSkeleton(
243+
"""
244+
@JS public func f<T: BridgedSwiftGenericBridgeable>(_ v: T) throws(JSException) -> T { v }
245+
"""
247246
)
247+
let function = try #require(skeleton.exported?.functions.first)
248+
#expect(function.effects.isThrows)
249+
#expect(function.genericParameterNames == ["T"])
248250
}
249251

250252
@Test(arguments: [
@@ -307,16 +309,18 @@ import Testing
307309
}
308310

309311
@Test
310-
func genericInstanceMethodThrowsIsRejected() {
311-
expectDiagnostic(
312-
source: """
313-
@JS class Box {
314-
@JS init() {}
315-
@JS func wrap<T: BridgedSwiftGenericBridgeable>(_ v: T) throws(JSException) -> T { v }
316-
}
317-
""",
318-
contains: "Generic @JS functions cannot be 'throws' yet."
312+
func genericInstanceMethodThrowsIsAccepted() throws {
313+
let skeleton = try makeSkeleton(
314+
"""
315+
@JS class Box {
316+
@JS init() {}
317+
@JS func wrap<T: BridgedSwiftGenericBridgeable>(_ v: T) throws(JSException) -> T { v }
318+
}
319+
"""
319320
)
321+
let method = try #require(skeleton.exported?.classes.first?.methods.first)
322+
#expect(method.effects.isThrows)
323+
#expect(method.genericParameterNames == ["T"])
320324
}
321325

322326
@Test

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,32 @@ 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
186+
}
187+
}
188+
163189
// A namespaced final class: its token is `ExportGenericNamespace_Handle`, but
164190
// its d.ts interface is emitted at the top level, so the token table must not
165191
// spell the type as `ExportGenericNamespace.Handle`.

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

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1045,6 +1045,45 @@
10451045
"_0" : "T"
10461046
}
10471047
}
1048+
},
1049+
{
1050+
"abiName" : "bjs_genericPickOrThrow",
1051+
"effects" : {
1052+
"isAsync" : false,
1053+
"isStatic" : false,
1054+
"isThrows" : true
1055+
},
1056+
"genericParameters" : [
1057+
{
1058+
"name" : "T"
1059+
}
1060+
],
1061+
"name" : "genericPickOrThrow",
1062+
"parameters" : [
1063+
{
1064+
"label" : "_",
1065+
"name" : "value",
1066+
"type" : {
1067+
"generic" : {
1068+
"_0" : "T"
1069+
}
1070+
}
1071+
},
1072+
{
1073+
"label" : "_",
1074+
"name" : "shouldThrow",
1075+
"type" : {
1076+
"bool" : {
1077+
1078+
}
1079+
}
1080+
}
1081+
],
1082+
"returnType" : {
1083+
"generic" : {
1084+
"_0" : "T"
1085+
}
1086+
}
10481087
}
10491088
],
10501089
"protocols" : [
@@ -1326,6 +1365,87 @@
13261365

13271366
],
13281367
"swiftCallName" : "GenericPair"
1368+
},
1369+
{
1370+
"constructor" : {
1371+
"abiName" : "bjs_GenericThrowingBox_init",
1372+
"effects" : {
1373+
"isAsync" : false,
1374+
"isStatic" : false,
1375+
"isThrows" : false
1376+
},
1377+
"parameters" : [
1378+
1379+
]
1380+
},
1381+
"methods" : [
1382+
{
1383+
"abiName" : "bjs_GenericThrowingBox_reject",
1384+
"effects" : {
1385+
"isAsync" : false,
1386+
"isStatic" : false,
1387+
"isThrows" : true
1388+
},
1389+
"genericParameters" : [
1390+
{
1391+
"name" : "T"
1392+
}
1393+
],
1394+
"name" : "reject",
1395+
"parameters" : [
1396+
{
1397+
"label" : "_",
1398+
"name" : "value",
1399+
"type" : {
1400+
"generic" : {
1401+
"_0" : "T"
1402+
}
1403+
}
1404+
}
1405+
],
1406+
"returnType" : {
1407+
"generic" : {
1408+
"_0" : "T"
1409+
}
1410+
}
1411+
},
1412+
{
1413+
"abiName" : "bjs_GenericThrowingBox_passThrough",
1414+
"effects" : {
1415+
"isAsync" : false,
1416+
"isStatic" : false,
1417+
"isThrows" : false
1418+
},
1419+
"genericParameters" : [
1420+
{
1421+
"name" : "T"
1422+
}
1423+
],
1424+
"jsName" : "passThrough",
1425+
"name" : "forward",
1426+
"parameters" : [
1427+
{
1428+
"label" : "_",
1429+
"name" : "value",
1430+
"type" : {
1431+
"generic" : {
1432+
"_0" : "T"
1433+
}
1434+
}
1435+
}
1436+
],
1437+
"returnType" : {
1438+
"generic" : {
1439+
"_0" : "T"
1440+
}
1441+
}
1442+
}
1443+
],
1444+
"name" : "GenericThrowingBox",
1445+
"properties" : [
1446+
1447+
],
1448+
"swiftCallName" : "GenericThrowingBox"
13291449
}
13301450
]
13311451
},

0 commit comments

Comments
 (0)