From 6ee6c4cfd889f067375d5459b0c23afd7e3056e8 Mon Sep 17 00:00:00 2001 From: yash Date: Fri, 19 Jun 2026 10:14:33 +0530 Subject: [PATCH 01/15] feat(compiler): add Swift gRPC service generation Schemas with services now emit a Grpc.swift companion beside the Swift model. Each service gets Fory-backed async and NIO providers plus an async client; request and response bytes ride a private GRPCPayload wrapper that serializes through the schema module's Fory instance. --- .../generators/services/swift.py | 511 ++++++++++++++++++ compiler/fory_compiler/generators/swift.py | 3 +- .../tests/test_service_codegen.py | 1 + 3 files changed, 514 insertions(+), 1 deletion(-) create mode 100644 compiler/fory_compiler/generators/services/swift.py diff --git a/compiler/fory_compiler/generators/services/swift.py b/compiler/fory_compiler/generators/services/swift.py new file mode 100644 index 0000000000..8baf71ff2a --- /dev/null +++ b/compiler/fory_compiler/generators/services/swift.py @@ -0,0 +1,511 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Swift gRPC service companion generator (grpc-swift v1).""" + +from typing import Dict, List, Set + +from fory_compiler.generators.base import GeneratedFile +from fory_compiler.generators.services.base import StreamingMode, streaming_mode +from fory_compiler.ir.ast import RpcMethod, Service + +# Availability gate matching grpc-swift's async/await APIs. +_ASYNC_AVAILABLE = "@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *)" + + +class SwiftServiceMixin: + """Generates Swift gRPC service companions backed by Fory serialization.""" + + def generate_services(self) -> List[GeneratedFile]: + services = [s for s in self.schema.services if not self.is_imported_type(s)] + if not services: + return [] + self._check_swift_grpc_method_collisions(services) + return [self._generate_swift_service(service) for service in services] + + def _grpc_prefix(self) -> str: + return "_".join(self._package_components_for_schema(self.schema)) + + def _service_symbol(self, service: Service) -> str: + name = self.to_pascal_case(service.name) + prefix = self._grpc_prefix() + return f"{prefix}_{name}" if prefix else name + + def _swift_grpc_method_name(self, method: RpcMethod) -> str: + return self.safe_member_name(method.name) + + def _request_type(self, method: RpcMethod) -> str: + return self._named_type_reference(method.request_type) + + def _response_type(self, method: RpcMethod) -> str: + return self._named_type_reference(method.response_type) + + def _check_swift_grpc_method_collisions(self, services: List[Service]) -> None: + for service in services: + seen: Dict[str, str] = {} + for method in service.methods: + swift_name = self._swift_grpc_method_name(method).strip("`") + if swift_name in seen: + raise ValueError( + f"Swift gRPC method name collision in service {service.name}: " + f"{seen[swift_name]} and {method.name} both generate {swift_name}" + ) + seen[swift_name] = method.name + + def _generate_swift_service(self, service: Service) -> GeneratedFile: + base = self._service_symbol(service) + module = self.module_type_path() + methods = service.methods + modes = {streaming_mode(m) for m in methods} + + lines: List[str] = [] + lines.append(self.get_license_header("//")) + lines.append("") + lines.append("import Foundation") + lines.append("import GRPC") + lines.append("import NIOCore") + lines.append("import Fory") + lines.append("") + + if methods: + lines.extend(self._marshaller(module)) + lines.append("") + lines.extend(self._metadata(base, service)) + lines.append("") + lines.extend(self._adapters(base, modes)) + lines.extend(self._provider(base, service)) + lines.append("") + lines.extend(self._async_provider(base, service)) + lines.append("") + lines.extend(self._async_client(base, service)) + + content = "\n".join(lines).rstrip() + "\n" + package_path = ( + self.schema.package.replace(".", "/") if self.schema.package else "" + ) + file_name = f"{self.to_pascal_case(service.name)}Grpc.swift" + path = f"{package_path}/{file_name}" if package_path else file_name + return GeneratedFile(path=path, content=content) + + def _marshaller(self, module: str) -> List[str]: + # NIOCore.ByteBuffer is qualified because `import Fory` also exposes one. + return [ + "// Internal Fory wire wrapper for gRPC request and response messages.", + "private struct ForyMessage: GRPCPayload {", + " var value: Value", + " init(_ value: Value) { self.value = value }", + " init(serializedByteBuffer buffer: inout NIOCore.ByteBuffer) throws {", + " let bytes = buffer.readBytes(length: buffer.readableBytes) ?? []", + f" self.value = try {module}.getFory().deserialize(Data(bytes))", + " }", + " func serialize(into buffer: inout NIOCore.ByteBuffer) throws {", + f" buffer.writeBytes(try {module}.getFory().serialize(value))", + " }", + "}", + ] + + def _metadata(self, base: str, service: Service) -> List[str]: + full_name = self.get_grpc_service_name(service) + lines = [f"enum {base}Metadata {{"] + lines.append(" enum Methods {") + for method in service.methods: + name = self._swift_grpc_method_name(method) + lines.append(f" static let {name} = GRPCMethodDescriptor(") + lines.append(f' name: "{method.name}",') + lines.append( + f' path: "{self.get_grpc_method_path(service, method)}",' + ) + lines.append(f" type: {self._call_type(method)})") + lines.append(" }") + method_refs = ", ".join( + f"Methods.{self._swift_grpc_method_name(m)}" for m in service.methods + ) + lines.append(" static let serviceDescriptor = GRPCServiceDescriptor(") + lines.append(f' name: "{service.name}",') + lines.append(f' fullName: "{full_name}",') + lines.append(f" methods: [{method_refs}])") + lines.append("}") + return lines + + def _call_type(self, method: RpcMethod) -> str: + return { + StreamingMode.UNARY: ".unary", + StreamingMode.SERVER_STREAMING: ".serverStreaming", + StreamingMode.CLIENT_STREAMING: ".clientStreaming", + StreamingMode.BIDIRECTIONAL: ".bidirectionalStreaming", + }[streaming_mode(method)] + + def _adapters(self, base: str, modes: Set[StreamingMode]) -> List[str]: + streamed_response = bool( + modes & {StreamingMode.SERVER_STREAMING, StreamingMode.BIDIRECTIONAL} + ) + streamed_request = bool( + modes & {StreamingMode.CLIENT_STREAMING, StreamingMode.BIDIRECTIONAL} + ) + lines: List[str] = [] + if streamed_response: + lines += self._streaming_response_context(base) + lines.append("") + lines += self._async_response_stream(base) + lines.append("") + lines += self._client_response_stream(base) + lines.append("") + if StreamingMode.CLIENT_STREAMING in modes: + lines += self._unary_response_context(base) + lines.append("") + if streamed_request: + lines += self._async_request_stream(base) + lines.append("") + return lines + + def _streaming_response_context(self, base: str) -> List[str]: + return [ + f"public struct {base}StreamingResponseContext {{", + " fileprivate let base: StreamingResponseCallContext>", + " public var eventLoop: EventLoop { base.eventLoop }", + " @discardableResult", + " public func sendResponse(_ response: Response) -> EventLoopFuture {", + " base.sendResponse(ForyMessage(response))", + " }", + "}", + ] + + def _unary_response_context(self, base: str) -> List[str]: + return [ + f"public struct {base}UnaryResponseContext {{", + " fileprivate let base: UnaryResponseCallContext>", + " public var eventLoop: EventLoop { base.eventLoop }", + " public func respond(_ response: Response) {", + " base.responsePromise.succeed(ForyMessage(response))", + " }", + "}", + ] + + def _async_response_stream(self, base: str) -> List[str]: + return [ + _ASYNC_AVAILABLE, + f"public struct {base}AsyncResponseStream {{", + " fileprivate let base: GRPCAsyncResponseStreamWriter>", + " public func send(_ response: Response) async throws {", + " try await base.send(ForyMessage(response))", + " }", + "}", + ] + + def _async_request_stream(self, base: str) -> List[str]: + return [ + _ASYNC_AVAILABLE, + f"public struct {base}AsyncRequestStream: AsyncSequence {{", + " public typealias Element = Request", + " fileprivate let base: GRPCAsyncRequestStream>", + " public struct AsyncIterator: AsyncIteratorProtocol {", + " fileprivate var base: GRPCAsyncRequestStream>.AsyncIterator", + " public mutating func next() async throws -> Request? {", + " try await base.next()?.value", + " }", + " }", + " public func makeAsyncIterator() -> AsyncIterator {", + " AsyncIterator(base: base.makeAsyncIterator())", + " }", + "}", + ] + + def _client_response_stream(self, base: str) -> List[str]: + return [ + _ASYNC_AVAILABLE, + f"public struct {base}ResponseStream: AsyncSequence {{", + " public typealias Element = Response", + " fileprivate let base: GRPCAsyncResponseStream>", + " public struct AsyncIterator: AsyncIteratorProtocol {", + " fileprivate var base: GRPCAsyncResponseStream>.AsyncIterator", + " public mutating func next() async throws -> Response? {", + " try await base.next()?.value", + " }", + " }", + " public func makeAsyncIterator() -> AsyncIterator {", + " AsyncIterator(base: base.makeAsyncIterator())", + " }", + "}", + ] + + def _provider(self, base: str, service: Service) -> List[str]: + lines = [f"public protocol {base}Provider: CallHandlerProvider {{"] + for method in service.methods: + lines.extend(self._provider_requirement(base, method)) + lines.append("}") + lines.append("") + lines.append(f"extension {base}Provider {{") + lines.append( + f" public var serviceName: Substring " + f"{{ {base}Metadata.serviceDescriptor.fullName[...] }}" + ) + lines.append("") + lines.append( + " public func handle(method name: Substring, context: CallHandlerContext)" + ) + lines.append(" -> GRPCServerHandlerProtocol?") + lines.append(" {") + lines.append(" switch name {") + for method in service.methods: + lines.extend(self._provider_handler_case(base, method)) + lines.append(" default: return nil") + lines.append(" }") + lines.append(" }") + lines.append("}") + return lines + + def _provider_requirement(self, base: str, method: RpcMethod) -> List[str]: + name = self._swift_grpc_method_name(method) + req = self._request_type(method) + res = self._response_type(method) + mode = streaming_mode(method) + if mode is StreamingMode.UNARY: + return [ + f" func {name}(request: {req}, context: StatusOnlyCallContext)", + f" -> EventLoopFuture<{res}>", + ] + if mode is StreamingMode.SERVER_STREAMING: + return [ + f" func {name}(request: {req}, " + f"context: {base}StreamingResponseContext<{res}>)", + " -> EventLoopFuture", + ] + if mode is StreamingMode.CLIENT_STREAMING: + return [ + f" func {name}(context: {base}UnaryResponseContext<{res}>)", + f" -> EventLoopFuture<(StreamEvent<{req}>) -> Void>", + ] + return [ + f" func {name}(context: {base}StreamingResponseContext<{res}>)", + f" -> EventLoopFuture<(StreamEvent<{req}>) -> Void>", + ] + + def _provider_handler_case(self, base: str, method: RpcMethod) -> List[str]: + name = self._swift_grpc_method_name(method) + req = self._request_type(method) + res = self._response_type(method) + mode = streaming_mode(method) + head = [ + f' case "{method.name}":', + f" return {self._server_handler(mode)}(", + " context: context,", + f" requestDeserializer: GRPCPayloadDeserializer>(),", + f" responseSerializer: GRPCPayloadSerializer>(),", + " interceptors: [],", + ] + if mode is StreamingMode.UNARY: + head.append( + f" userFunction: {{ req, ctx in " + f"self.{name}(request: req.value, context: ctx).map {{ ForyMessage($0) }} }})" + ) + elif mode is StreamingMode.SERVER_STREAMING: + head.append( + f" userFunction: {{ req, ctx in self.{name}(" + f"request: req.value, context: {base}" + f"StreamingResponseContext(base: ctx)) }})" + ) + elif mode is StreamingMode.CLIENT_STREAMING: + head.extend( + self._client_stream_observer(base, name, req, "UnaryResponseContext") + ) + else: + head.extend( + self._client_stream_observer( + base, name, req, "StreamingResponseContext" + ) + ) + return head + + def _client_stream_observer( + self, base: str, name: str, req: str, ctx_kind: str + ) -> List[str]: + return [ + " observerFactory: { ctx in", + f" self.{name}(context: {base}{ctx_kind}(base: ctx))" + ".map { observer in", + f" {{ (event: StreamEvent>) in", + " switch event {", + " case .message(let m): observer(.message(m.value))", + " case .end: observer(.end)", + " @unknown default: break", + " }", + " }", + " }", + " })", + ] + + def _server_handler(self, mode: StreamingMode) -> str: + return { + StreamingMode.UNARY: "UnaryServerHandler", + StreamingMode.SERVER_STREAMING: "ServerStreamingServerHandler", + StreamingMode.CLIENT_STREAMING: "ClientStreamingServerHandler", + StreamingMode.BIDIRECTIONAL: "BidirectionalStreamingServerHandler", + }[mode] + + def _async_provider(self, base: str, service: Service) -> List[str]: + lines = [ + _ASYNC_AVAILABLE, + f"public protocol {base}AsyncProvider: CallHandlerProvider, Sendable {{", + ] + for method in service.methods: + lines.extend(self._async_provider_requirement(base, method)) + lines.append("}") + lines.append("") + lines.append(_ASYNC_AVAILABLE) + lines.append(f"extension {base}AsyncProvider {{") + lines.append( + f" public var serviceName: Substring " + f"{{ {base}Metadata.serviceDescriptor.fullName[...] }}" + ) + lines.append("") + lines.append( + " public func handle(method name: Substring, context: CallHandlerContext)" + ) + lines.append(" -> GRPCServerHandlerProtocol?") + lines.append(" {") + lines.append(" switch name {") + for method in service.methods: + lines.extend(self._async_provider_handler_case(base, method)) + lines.append(" default: return nil") + lines.append(" }") + lines.append(" }") + lines.append("}") + return lines + + def _async_provider_requirement(self, base: str, method: RpcMethod) -> List[str]: + name = self._swift_grpc_method_name(method) + req = self._request_type(method) + res = self._response_type(method) + mode = streaming_mode(method) + if mode is StreamingMode.UNARY: + return [ + f" func {name}(request: {req}, context: GRPCAsyncServerCallContext)" + f" async throws -> {res}", + ] + if mode is StreamingMode.SERVER_STREAMING: + return [ + f" func {name}(request: {req}, " + f"responseStream: {base}AsyncResponseStream<{res}>,", + " context: GRPCAsyncServerCallContext) async throws", + ] + if mode is StreamingMode.CLIENT_STREAMING: + return [ + f" func {name}(requestStream: {base}AsyncRequestStream<{req}>,", + f" context: GRPCAsyncServerCallContext) async throws -> {res}", + ] + return [ + f" func {name}(requestStream: {base}AsyncRequestStream<{req}>,", + f" responseStream: {base}AsyncResponseStream<{res}>,", + " context: GRPCAsyncServerCallContext) async throws", + ] + + def _async_provider_handler_case(self, base: str, method: RpcMethod) -> List[str]: + name = self._swift_grpc_method_name(method) + req = self._request_type(method) + res = self._response_type(method) + mode = streaming_mode(method) + head = [ + f' case "{method.name}":', + " return GRPCAsyncServerHandler(", + " context: context,", + f" requestDeserializer: GRPCPayloadDeserializer>(),", + f" responseSerializer: GRPCPayloadSerializer>(),", + " interceptors: [],", + ] + if mode is StreamingMode.UNARY: + head.append( + f" wrapping: {{ ForyMessage(" + f"try await self.{name}(request: $0.value, context: $1)) }})" + ) + elif mode is StreamingMode.SERVER_STREAMING: + head.append( + f" wrapping: {{ try await self.{name}(request: $0.value, " + f"responseStream: {base}AsyncResponseStream(base: $1), context: $2) }})" + ) + elif mode is StreamingMode.CLIENT_STREAMING: + head.append( + f" wrapping: {{ ForyMessage(try await self.{name}(" + f"requestStream: {base}AsyncRequestStream(base: $0), context: $1)) }})" + ) + else: + head.append( + f" wrapping: {{ try await self.{name}(" + f"requestStream: {base}AsyncRequestStream(base: $0), " + f"responseStream: {base}AsyncResponseStream(base: $1), context: $2) }})" + ) + return head + + def _async_client(self, base: str, service: Service) -> List[str]: + lines = [ + _ASYNC_AVAILABLE, + f"public struct {base}AsyncClient: GRPCClient {{", + " public var channel: GRPCChannel", + " public var defaultCallOptions: CallOptions", + " public init(channel: GRPCChannel, defaultCallOptions: CallOptions = CallOptions()) {", + " self.channel = channel", + " self.defaultCallOptions = defaultCallOptions", + " }", + ] + for method in service.methods: + lines.append("") + lines.extend(self._async_client_method(base, method)) + lines.append("}") + return lines + + def _async_client_method(self, base: str, method: RpcMethod) -> List[str]: + name = self._swift_grpc_method_name(method) + req = self._request_type(method) + res = self._response_type(method) + path = f"{base}Metadata.Methods.{name}.path" + mode = streaming_mode(method) + if mode is StreamingMode.UNARY: + return [ + f" public func {name}(_ request: {req}) async throws -> {res} {{", + f" let response: ForyMessage<{res}> = try await performAsyncUnaryCall(", + f" path: {path},", + " request: ForyMessage(request), callOptions: defaultCallOptions)", + " return response.value", + " }", + ] + if mode is StreamingMode.SERVER_STREAMING: + return [ + f" public func {name}(_ request: {req}) -> {base}ResponseStream<{res}> {{", + f" {base}ResponseStream(base: performAsyncServerStreamingCall(", + f" path: {path},", + " request: ForyMessage(request), callOptions: defaultCallOptions))", + " }", + ] + if mode is StreamingMode.CLIENT_STREAMING: + return [ + f" public func {name}(_ requests: S)" + f" async throws -> {res}", + f" where S.Element == {req} {{", + f" let response: ForyMessage<{res}> = try await performAsyncClientStreamingCall(", + f" path: {path},", + " requests: requests.map { ForyMessage($0) }, callOptions: defaultCallOptions)", + " return response.value", + " }", + ] + return [ + f" public func {name}(_ requests: S)" + f" -> {base}ResponseStream<{res}>", + f" where S.Element == {req} {{", + f" {base}ResponseStream(base: performAsyncBidirectionalStreamingCall(", + f" path: {path},", + " requests: requests.map { ForyMessage($0) }, callOptions: defaultCallOptions))", + " }", + ] diff --git a/compiler/fory_compiler/generators/swift.py b/compiler/fory_compiler/generators/swift.py index 880d220caf..32b0c635bb 100644 --- a/compiler/fory_compiler/generators/swift.py +++ b/compiler/fory_compiler/generators/swift.py @@ -22,6 +22,7 @@ from fory_compiler.frontend.utils import parse_idl_file from fory_compiler.generators.base import BaseGenerator, GeneratedFile +from fory_compiler.generators.services.swift import SwiftServiceMixin from fory_compiler.ir.ast import ( ArrayType, Enum, @@ -38,7 +39,7 @@ from fory_compiler.ir.types import PrimitiveKind -class SwiftGenerator(BaseGenerator): +class SwiftGenerator(SwiftServiceMixin, BaseGenerator): """Generates Swift types using Fory Swift model macros.""" language_name = "swift" diff --git a/compiler/fory_compiler/tests/test_service_codegen.py b/compiler/fory_compiler/tests/test_service_codegen.py index b7d2d2a972..758d780ca2 100644 --- a/compiler/fory_compiler/tests/test_service_codegen.py +++ b/compiler/fory_compiler/tests/test_service_codegen.py @@ -155,6 +155,7 @@ def test_unsupported_generators_no_services(): ScalaGenerator, KotlinGenerator, JavaScriptGenerator, + SwiftGenerator, ): continue options = GeneratorOptions(output_dir=Path("/tmp")) From 9d15a1b6be7817ca25ea3470a221ca5c4ff766b4 Mon Sep 17 00:00:00 2001 From: yash Date: Fri, 19 Jun 2026 11:52:08 +0530 Subject: [PATCH 02/15] feat(compiler): preflight Swift gRPC collisions Before writing Swift output, check that no two schemas or services claim the same file path or top-level symbol. A service named after a generated type, or a duplicate service, now fails fast with a clear message instead of emitting Swift that will not compile. --- compiler/fory_compiler/cli.py | 27 +++++++ .../generators/services/swift.py | 27 +++++++ compiler/fory_compiler/generators/swift.py | 72 ++++++++++++++++++- 3 files changed, 124 insertions(+), 2 deletions(-) diff --git a/compiler/fory_compiler/cli.py b/compiler/fory_compiler/cli.py index 7410e727b1..d9dbfbe077 100644 --- a/compiler/fory_compiler/cli.py +++ b/compiler/fory_compiler/cli.py @@ -32,6 +32,7 @@ from fory_compiler.generators.base import GeneratorOptions from fory_compiler.generators import GENERATORS from fory_compiler.generators.csharp import validate_csharp_generation +from fory_compiler.generators.swift import validate_swift_generation from fory_compiler.generators.kotlin import ( kotlin_output_paths, kotlin_package_for_schema, @@ -342,6 +343,27 @@ def validate_csharp_files( return False +def validate_swift_files( + files: List[Path], + import_paths: List[Path], + namespace_style: Optional[str] = None, + grpc: bool = False, +) -> bool: + """Preflight Swift output paths and top-level symbols before writing output.""" + cache: Dict[Path, Schema] = {} + graph: List[Tuple[Path, Schema]] = [] + for file_path in files: + file_graph = collect_schema_graph(file_path, import_paths, cache, set()) + if file_graph is None: + return False + graph.extend(file_graph) + try: + return validate_swift_generation(graph, namespace_style, grpc=grpc) + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + return False + + def validate_scala_import_packages(graph: List[Tuple[Path, Schema]]) -> bool: """Check package combinations that Scala source cannot compile.""" packages = {scala_package_for_schema(schema) for _, schema in graph} @@ -1022,6 +1044,11 @@ def cmd_compile(args: argparse.Namespace) -> int: if "scala" in lang_output_dirs: if not validate_scala_generation(args.files, import_paths, grpc=args.grpc): return 1 + if "swift" in lang_output_dirs: + if not validate_swift_files( + args.files, import_paths, args.swift_namespace_style, grpc=args.grpc + ): + return 1 if args.grpc_web and "javascript" not in lang_output_dirs: print( diff --git a/compiler/fory_compiler/generators/services/swift.py b/compiler/fory_compiler/generators/services/swift.py index 8baf71ff2a..47ab2b6ac7 100644 --- a/compiler/fory_compiler/generators/services/swift.py +++ b/compiler/fory_compiler/generators/services/swift.py @@ -45,6 +45,33 @@ def _service_symbol(self, service: Service) -> str: prefix = self._grpc_prefix() return f"{prefix}_{name}" if prefix else name + def swift_grpc_output_path(self, service: Service) -> str: + package = self.schema.package + package_path = package.replace(".", "/") if package else "" + file_name = f"{self.to_pascal_case(service.name)}Grpc.swift" + return f"{package_path}/{file_name}" if package_path else file_name + + def swift_grpc_service_symbols(self, service: Service) -> List[str]: + base = self._service_symbol(service) + modes = {streaming_mode(m) for m in service.methods} + symbols = [ + f"{base}Metadata", + f"{base}Provider", + f"{base}AsyncProvider", + f"{base}AsyncClient", + ] + if modes & {StreamingMode.SERVER_STREAMING, StreamingMode.BIDIRECTIONAL}: + symbols += [ + f"{base}StreamingResponseContext", + f"{base}AsyncResponseStream", + f"{base}ResponseStream", + ] + if StreamingMode.CLIENT_STREAMING in modes: + symbols.append(f"{base}UnaryResponseContext") + if modes & {StreamingMode.CLIENT_STREAMING, StreamingMode.BIDIRECTIONAL}: + symbols.append(f"{base}AsyncRequestStream") + return symbols + def _swift_grpc_method_name(self, method: RpcMethod) -> str: return self.safe_member_name(method.name) diff --git a/compiler/fory_compiler/generators/swift.py b/compiler/fory_compiler/generators/swift.py index 32b0c635bb..b3b88faa98 100644 --- a/compiler/fory_compiler/generators/swift.py +++ b/compiler/fory_compiler/generators/swift.py @@ -18,10 +18,10 @@ """Swift code generator.""" from pathlib import Path -from typing import Dict, List, Optional, Set, Union as TypingUnion +from typing import Dict, List, Optional, Set, Tuple, Union as TypingUnion from fory_compiler.frontend.utils import parse_idl_file -from fory_compiler.generators.base import BaseGenerator, GeneratedFile +from fory_compiler.generators.base import BaseGenerator, GeneratedFile, GeneratorOptions from fory_compiler.generators.services.swift import SwiftServiceMixin from fory_compiler.ir.ast import ( ArrayType, @@ -193,6 +193,21 @@ def output_file_path(self) -> str: return f"{package_path}/{file_name}" return file_name + def swift_model_top_level_symbols(self) -> Set[str]: + # In enum style with a package, types live nested under a shared namespace + # enum, so there are no collidable top-level names. Flatten and default + # packages put each type and the module helper at file scope. + components = self._package_components_for_schema(self.schema) + if self.get_namespace_style() == "enum" and components: + return set() + symbols: Set[str] = set() + for type_def in self.schema.enums + self.schema.unions + self.schema.messages: + if self.is_imported_type(type_def): + continue + symbols.add(self._declared_type_name(type_def.name)) + symbols.add(self._module_helper_name_for_schema(self.schema)) + return symbols + def module_file_name(self) -> str: if self.schema.source_file and not self.schema.source_file.startswith("<"): stem = Path(self.schema.source_file).stem @@ -1273,3 +1288,56 @@ def generate_module_type(self, indent: int = 0) -> List[str]: lines.append(f"{ind}" + "}") return lines + + +def validate_swift_generation( + graph: List[Tuple[Path, Schema]], + namespace_style: Optional[str] = None, + grpc: bool = False, +) -> bool: + """Preflight Swift output paths and top-level symbol owners before writing.""" + output_owners: Dict[str, List[str]] = {} + symbol_owners: Dict[str, List[str]] = {} + for path, schema in graph: + options = GeneratorOptions( + output_dir=Path("."), swift_namespace_style=namespace_style + ) + generator = SwiftGenerator(schema, options) + output_owners.setdefault(generator.output_file_path(), []).append( + f"{path} schema module" + ) + for symbol in generator.swift_model_top_level_symbols(): + symbol_owners.setdefault(symbol, []).append(f"{path} schema type") + if grpc: + for service in schema.services: + if generator.is_imported_type(service): + continue + output_owners.setdefault( + generator.swift_grpc_output_path(service), [] + ).append(f"{path} service {service.name}") + for symbol in generator.swift_grpc_service_symbols(service): + symbol_owners.setdefault(symbol, []).append( + f"{path} service {service.name}" + ) + + _raise_swift_collision( + output_owners, + "Swift generated file path collision; rename schema files or services, " + "or use distinct packages", + ) + _raise_swift_collision( + symbol_owners, + "Swift top-level symbol collision; rename schema types or services, " + "or use distinct packages", + ) + return True + + +def _raise_swift_collision(owners: Dict[str, List[str]], message: str) -> None: + collisions = {key: names for key, names in owners.items() if len(names) > 1} + if not collisions: + return + details = ", ".join( + f"{key}: {', '.join(names)}" for key, names in sorted(collisions.items()) + ) + raise ValueError(f"{message}. Collisions: {details}") From e78e79e458b47b7dbbb9ac6f8079c3b93ffe32ba Mon Sep 17 00:00:00 2001 From: yash Date: Fri, 19 Jun 2026 14:21:45 +0530 Subject: [PATCH 03/15] test(compiler): cover Swift gRPC codegen Exercise the Swift companion across the four streaming shapes, keyword-escaped methods, imported request and response types, the default package, both IDL frontends, and the collision preflight, so the emitter and its symbol names stay pinned. --- .../tests/test_service_codegen.py | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) diff --git a/compiler/fory_compiler/tests/test_service_codegen.py b/compiler/fory_compiler/tests/test_service_codegen.py index 758d780ca2..2de8037f07 100644 --- a/compiler/fory_compiler/tests/test_service_codegen.py +++ b/compiler/fory_compiler/tests/test_service_codegen.py @@ -32,6 +32,7 @@ parse_args, resolve_imports, validate_scala_generation, + validate_swift_files, ) from fory_compiler.frontend.fdl.lexer import Lexer from fory_compiler.frontend.fdl.parser import Parser @@ -969,6 +970,113 @@ def test_scala_grpc_marshaller(): assert "org.apache.fory.scala.grpc" not in content +def test_swift_grpc_fory_marshaller(): + schema = parse_fdl(_GREETER_WITH_SERVICE) + files = generate_service_files(schema, SwiftGenerator) + assert set(files) == {"demo/greeter/GreeterGrpc.swift"} + content = files["demo/greeter/GreeterGrpc.swift"] + assert "private struct ForyMessage: GRPCPayload" in content + assert "Demo.Greeter.ForyModule.getFory()" in content + assert "enum Demo_Greeter_GreeterMetadata" in content + assert 'fullName: "demo.greeter.Greeter"' in content + assert ( + "public protocol Demo_Greeter_GreeterProvider: CallHandlerProvider" in content + ) + assert ( + "public protocol Demo_Greeter_GreeterAsyncProvider: CallHandlerProvider, Sendable" + in content + ) + assert "public struct Demo_Greeter_GreeterAsyncClient: GRPCClient" in content + assert "return UnaryServerHandler(" in content + assert "func sayHello(" in content + # Fory carries the bytes; no protobuf and no Java/C# transliteration. + assert "ProtobufSerializer" not in content + assert "import SwiftProtobuf" not in content + assert "enum GreeterGrpc" not in content + + +def test_swift_grpc_default_package(): + schema = parse_fdl( + dedent( + """ + message Req {} + message Res {} + service Greeter { rpc SayHello (Req) returns (Res); } + """ + ) + ) + files = generate_service_files(schema, SwiftGenerator) + assert set(files) == {"GreeterGrpc.swift"} + content = files["GreeterGrpc.swift"] + assert "enum GreeterMetadata" in content + assert "public protocol GreeterProvider: CallHandlerProvider" in content + assert "public struct GreeterAsyncClient: GRPCClient" in content + assert "ForyModule.getFory()" in content + # No package means no name prefix. + assert "Demo_" not in content + + +def test_swift_grpc_preflight_collision(tmp_path: Path, capsys): + # In flatten style a service provider and a like-named message both land at + # file scope, so the preflight must reject the clash. + main = tmp_path / "main.fdl" + main.write_text( + dedent( + """ + package demo.collision; + + message GreeterProvider {} + message Req {} + message Res {} + + service Greeter { + rpc Call (Req) returns (Res); + } + """ + ) + ) + assert validate_swift_files([main], [tmp_path], "flatten", grpc=True) is False + err = capsys.readouterr().err + assert "Swift top-level symbol collision" in err + assert "Demo_Collision_GreeterProvider" in err + + +def test_swift_grpc_imported_types(tmp_path: Path): + common = tmp_path / "common.fdl" + common.write_text( + dedent( + """ + package demo.shared; + + message SharedRequest { string name = 1; } + message SharedReply { string text = 1; } + """ + ) + ) + main = tmp_path / "main.fdl" + main.write_text( + dedent( + """ + package demo.greeter; + + import "common.fdl"; + + service Greeter { + rpc Call (SharedRequest) returns (SharedReply); + } + """ + ) + ) + schema = resolve_imports(main, [tmp_path]) + files = generate_service_files(schema, SwiftGenerator) + assert set(files) == {"demo/greeter/GreeterGrpc.swift"} + content = files["demo/greeter/GreeterGrpc.swift"] + # Imported request and response types are referenced in their own namespace. + assert "Demo.Shared.SharedRequest" in content + assert "Demo.Shared.SharedReply" in content + assert "Demo.Greeter.ForyModule.getFory()" in content + + def test_grpc_streaming_method_shapes(): schema = parse_fdl( dedent( @@ -1102,6 +1210,39 @@ def test_grpc_streaming_method_shapes(): assert "call.sendMessage(request)" in scala assert "call.halfClose()" in scala + swift = next(iter(generate_service_files(schema, SwiftGenerator).values())) + assert "type: .unary)" in swift + assert "type: .serverStreaming)" in swift + assert "type: .clientStreaming)" in swift + assert "type: .bidirectionalStreaming)" in swift + assert "return UnaryServerHandler(" in swift + assert "return ServerStreamingServerHandler(" in swift + assert "return ClientStreamingServerHandler(" in swift + assert "return BidirectionalStreamingServerHandler(" in swift + assert ( + "func unary(request: Demo.Streams.Req, context: GRPCAsyncServerCallContext)" + " async throws -> Demo.Streams.Res" in swift + ) + assert ( + "responseStream: Demo_Streams_StreamerAsyncResponseStream" + in swift + ) + assert ( + "requestStream: Demo_Streams_StreamerAsyncRequestStream" + in swift + ) + assert ( + "public func unary(_ request: Demo.Streams.Req) async throws -> Demo.Streams.Res" + in swift + ) + assert ( + "public func server(_ request: Demo.Streams.Req)" + " -> Demo_Streams_StreamerResponseStream" in swift + ) + assert "performAsyncClientStreamingCall(" in swift + assert "performAsyncBidirectionalStreamingCall(" in swift + assert "ProtobufSerializer" not in swift + def test_go_grpc_service_codegen(): schema = parse_fdl(_GREETER_WITH_SERVICE) @@ -1820,6 +1961,12 @@ def test_grpc_method_keywords_safe(): assert "def `class`(request: Req, responseObserver:" in scala assert 'SERVICE_NAME,\n "Class"' in scala + swift = next(iter(generate_service_files(schema, SwiftGenerator).values())) + assert "func `class`(request: Demo.Keywords.Req" in swift + assert "public func `class`(_ request: Demo.Keywords.Req)" in swift + assert 'case "Class":' in swift + assert "Demo_Keywords_GreeterMetadata.Methods.`class`" in swift + def test_python_grpc_registration_collision(): schema = parse_fdl( @@ -1909,13 +2056,16 @@ def test_proto_and_fbs_grpc_service_codegen(): proto_java = generate_service_files(proto_schema, JavaGenerator) proto_python = generate_service_files(proto_schema, PythonGenerator) proto_scala = generate_service_files(proto_schema, ScalaGenerator) + proto_swift = generate_service_files(proto_schema, SwiftGenerator) assert "demo/proto/ProtoSvcGrpc.java" in proto_java assert "demo_proto_grpc.py" in proto_python assert "demo/proto/ProtoSvcGrpc.scala" in proto_scala + assert "demo/proto/ProtoSvcGrpc.swift" in proto_swift assert "MethodType.SERVER_STREAMING" in proto_java["demo/proto/ProtoSvcGrpc.java"] assert "channel.unary_stream(" in proto_python["demo_proto_grpc.py"] assert "MethodType.SERVER_STREAMING" in proto_scala["demo/proto/ProtoSvcGrpc.scala"] assert "RpcIterator[Res]" in proto_scala["demo/proto/ProtoSvcGrpc.scala"] + assert "type: .serverStreaming)" in proto_swift["demo/proto/ProtoSvcGrpc.swift"] fbs_schema = parse_fbs( dedent( @@ -1934,9 +2084,12 @@ def test_proto_and_fbs_grpc_service_codegen(): fbs_java = generate_service_files(fbs_schema, JavaGenerator) fbs_python = generate_service_files(fbs_schema, PythonGenerator) fbs_scala = generate_service_files(fbs_schema, ScalaGenerator) + fbs_swift = generate_service_files(fbs_schema, SwiftGenerator) assert "demo/fbs/FbsSvcGrpc.java" in fbs_java assert "demo_fbs_grpc.py" in fbs_python assert "demo/fbs/FbsSvcGrpc.scala" in fbs_scala + assert "demo/fbs/FbsSvcGrpc.swift" in fbs_swift + assert 'fullName: "demo.fbs.FbsSvc"' in fbs_swift["demo/fbs/FbsSvcGrpc.swift"] assert 'SERVICE_NAME = "demo.fbs.FbsSvc"' in fbs_java["demo/fbs/FbsSvcGrpc.java"] assert '"/demo.fbs.FbsSvc/Call"' in fbs_python["demo_fbs_grpc.py"] assert ( From b554dae3306708df1fd9e6a8753d87f3611ce926 Mon Sep 17 00:00:00 2001 From: yash Date: Fri, 19 Jun 2026 16:09:52 +0530 Subject: [PATCH 04/15] test(compiler): build and run the Swift gRPC fixture Generate a two-package schema, then swift build and run a SwiftPM package on grpc-swift and local Fory that hosts the generated provider and round-trips all four streaming shapes across the import boundary. Skipped when swift is absent. --- .../tests/test_service_codegen.py | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) diff --git a/compiler/fory_compiler/tests/test_service_codegen.py b/compiler/fory_compiler/tests/test_service_codegen.py index 2de8037f07..a8553b8f17 100644 --- a/compiler/fory_compiler/tests/test_service_codegen.py +++ b/compiler/fory_compiler/tests/test_service_codegen.py @@ -2399,6 +2399,89 @@ def test_csharp_grpc_dotnet_fixture(tmp_path: Path): assert result.returncode == 0, result.stdout + result.stderr +@pytest.mark.skipif(shutil.which("swift") is None, reason="swift not installed") +def test_swift_grpc_swiftpm_fixture(tmp_path: Path): + repo_root = Path(__file__).resolve().parents[3] + common = tmp_path / "common.fdl" + common.write_text( + dedent( + """ + package shared.models; + + message SharedRequest { string name = 1; } + message SharedReply { string text = 1; } + """ + ) + ) + main = tmp_path / "main.fdl" + main.write_text( + dedent( + """ + package greeter.api; + + import "common.fdl"; + + message LocalRequest { string name = 1; } + message LocalReply { string text = 1; } + + service Greeter { + rpc Unary (LocalRequest) returns (LocalReply); + rpc Server (LocalRequest) returns (stream LocalReply); + rpc Client (stream SharedRequest) returns (SharedReply); + rpc Bidi (stream LocalRequest) returns (stream SharedReply); + } + + service Empty {} + """ + ) + ) + pkg = tmp_path / "pkg" + app = pkg / "Sources" / "App" + app.mkdir(parents=True) + assert foryc_main(["--swift_out", str(app), "--grpc", str(common), str(main)]) == 0 + assert (app / "greeter" / "api" / "GreeterGrpc.swift").is_file() + assert (app / "greeter" / "api" / "EmptyGrpc.swift").is_file() + + (pkg / "Package.swift").write_text( + dedent( + f""" + // swift-tools-version:5.9 + import PackageDescription + let package = Package( + name: "App", + platforms: [.macOS(.v13)], + dependencies: [ + .package(url: "https://github.com/grpc/grpc-swift.git", exact: "1.24.2"), + .package(path: "{repo_root / "swift"}"), + ], + targets: [ + .executableTarget( + name: "App", + dependencies: [ + .product(name: "GRPC", package: "grpc-swift"), + .product(name: "Fory", package: "swift"), + ], + path: "Sources/App" + ) + ] + ) + """ + ).strip() + ) + (app / "main.swift").write_text(_SWIFT_GRPC_VALIDATION_PROGRAM) + + result = subprocess.run( + ["swift", "run"], + cwd=pkg, + text=True, + capture_output=True, + timeout=900, + check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert "GENERATED OK" in result.stdout + + def test_generated_message_signatures(): schema = parse_fdl(_GREETER_WITH_SERVICE) java_files = generate_files(schema, JavaGenerator) @@ -3122,3 +3205,68 @@ def test_rust_grpc_rejects_unsafe_refs(): ) with pytest.raises(ValueError, match=message): generator.generate_services() + + +_SWIFT_GRPC_VALIDATION_PROGRAM = r""" +import Foundation +import GRPC +import NIOCore +import NIOPosix + +@available(macOS 10.15, *) +final class GreeterImpl: Greeter_Api_GreeterAsyncProvider { + func unary(request: Greeter.Api.LocalRequest, context: GRPCAsyncServerCallContext) + async throws -> Greeter.Api.LocalReply { + Greeter.Api.LocalReply(text: "hi " + request.name) + } + func server(request: Greeter.Api.LocalRequest, + responseStream: Greeter_Api_GreeterAsyncResponseStream, + context: GRPCAsyncServerCallContext) async throws { + try await responseStream.send(Greeter.Api.LocalReply(text: "a:" + request.name)) + try await responseStream.send(Greeter.Api.LocalReply(text: "b:" + request.name)) + } + func client(requestStream: Greeter_Api_GreeterAsyncRequestStream, + context: GRPCAsyncServerCallContext) async throws -> Shared.Models.SharedReply { + var names: [String] = [] + for try await r in requestStream { names.append(r.name) } + return Shared.Models.SharedReply(text: "got:" + names.joined(separator: ",")) + } + func bidi(requestStream: Greeter_Api_GreeterAsyncRequestStream, + responseStream: Greeter_Api_GreeterAsyncResponseStream, + context: GRPCAsyncServerCallContext) async throws { + for try await r in requestStream { + try await responseStream.send(Shared.Models.SharedReply(text: "echo:" + r.name)) + } + } +} + +func names(_ values: [String]) -> AsyncStream { + AsyncStream { c in for v in values { c.yield(Shared.Models.SharedRequest(name: v)) }; c.finish() } +} +func locals(_ values: [String]) -> AsyncStream { + AsyncStream { c in for v in values { c.yield(Greeter.Api.LocalRequest(name: v)) }; c.finish() } +} + +guard #available(macOS 10.15, *) else { fatalError() } +let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) +defer { try? group.syncShutdownGracefully() } +let server = try Server.insecure(group: group).withServiceProviders([GreeterImpl()]) + .bind(host: "127.0.0.1", port: 0).wait() +let port = server.channel.localAddress!.port! +let channel = try GRPCChannelPool.with(target: .host("127.0.0.1", port: port), + transportSecurity: .plaintext, eventLoopGroup: group) +defer { try? channel.close().wait() } +let client = Greeter_Api_GreeterAsyncClient(channel: channel) + +let u = try await client.unary(Greeter.Api.LocalRequest(name: "world")) +precondition(u.text == "hi world") +var ss: [String] = [] +for try await m in client.server(Greeter.Api.LocalRequest(name: "x")) { ss.append(m.text) } +precondition(ss == ["a:x", "b:x"]) +let cs = try await client.client(names(["p", "q"])) +precondition(cs.text == "got:p,q") +var bd: [String] = [] +for try await m in client.bidi(locals(["m", "n"])) { bd.append(m.text) } +precondition(bd == ["echo:m", "echo:n"]) +print("GENERATED OK") +""" From e865db594ec6cda52b5918fe272cda6cf6cbc280 Mon Sep 17 00:00:00 2001 From: yash Date: Fri, 19 Jun 2026 18:33:17 +0530 Subject: [PATCH 05/15] test(grpc): generate Swift companions in the interop harness Wire Swift into the shared gRPC generation step so the interop schemas emit Swift companions alongside the other targets. --- integration_tests/grpc_tests/generate_grpc.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/integration_tests/grpc_tests/generate_grpc.py b/integration_tests/grpc_tests/generate_grpc.py index e89622b5cd..21b8d71d11 100644 --- a/integration_tests/grpc_tests/generate_grpc.py +++ b/integration_tests/grpc_tests/generate_grpc.py @@ -38,6 +38,7 @@ "rust": TEST_DIR / "rust/generated/src", "csharp": TEST_DIR / "csharp/generated", "kotlin": TEST_DIR / "kotlin/src/main/kotlin/generated", + "swift": TEST_DIR / "swift/generated", } @@ -81,6 +82,7 @@ def main() -> int: f"--rust_out={OUTPUTS['rust']}", f"--csharp_out={OUTPUTS['csharp']}", f"--kotlin_out={OUTPUTS['kotlin']}", + f"--swift_out={OUTPUTS['swift']}", "--grpc", ], env=env, From efdcad24e8ab2a18fbbe51ee4206f01a012f2b34 Mon Sep 17 00:00:00 2001 From: yash Date: Sat, 20 Jun 2026 09:47:21 +0530 Subject: [PATCH 06/15] docs: document Swift gRPC support Add a Swift gRPC guide covering dependencies, server and client usage, streaming, and troubleshooting, link it from the Swift guide index, and note the Swift companion in the compiler guide and agent rules. --- .agents/languages/swift.md | 1 + docs/compiler/compiler-guide.md | 10 +- docs/guide/swift/grpc-support.md | 202 +++++++++++++++++++++++++++++++ docs/guide/swift/index.md | 1 + 4 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 docs/guide/swift/grpc-support.md diff --git a/.agents/languages/swift.md b/.agents/languages/swift.md index cfd5198524..fe6e92bb10 100644 --- a/.agents/languages/swift.md +++ b/.agents/languages/swift.md @@ -9,6 +9,7 @@ Load this file when changing `swift/` or Swift xlang behavior. - Swift code must compile without compiler warnings. Treat warnings as blockers, including warnings in generated Swift code. - Swift lint uses `swift/.swiftlint.yml`. - Use `ENABLE_FORY_DEBUG_OUTPUT=1` when debugging Swift tests. +- Generated Swift gRPC companions are compiler-owned files targeting grpc-swift 1.x. Keep grpc-swift out of the `swift/` runtime package; it belongs only to generated user code and the compiler build fixture. - Prefer the user-requested or existing Foundation public value type when it is the intended Swift surface; do not invent Fory-prefixed wrappers only to avoid import ambiguity. - Preserve distinct temporal semantics. Timestamp values and day-only local dates should have protocol-accurate helper names and no stale aliases after a refactor. - When temporal or public-type refactors touch generated Swift code, sweep message fields, union payloads, macros, xlang harnesses, and integration fixtures together. diff --git a/docs/compiler/compiler-guide.md b/docs/compiler/compiler-guide.md index 0c5ebd4912..5ee2419943 100644 --- a/docs/compiler/compiler-guide.md +++ b/docs/compiler/compiler-guide.md @@ -149,19 +149,21 @@ foryc user.fdl order.fdl product.fdl --output ./generated foryc compiler/examples/service.fdl --java_out=./generated/java --python_out=./generated/python --go_out=./generated/go --rust_out=./generated/rust --csharp_out=./generated/csharp --scala_out=./generated/scala --kotlin_out=./generated/kotlin --javascript_out=./generated/javascript ``` -**Generate Java, Python, Go, Rust, C#, Scala, Kotlin, and Node.js JavaScript gRPC service companions:** +**Generate Java, Python, Go, Rust, C#, Scala, Kotlin, Node.js JavaScript, and Swift gRPC service companions:** ```bash -foryc compiler/examples/service.fdl --java_out=./generated/java --python_out=./generated/python --go_out=./generated/go --rust_out=./generated/rust --csharp_out=./generated/csharp --scala_out=./generated/scala --kotlin_out=./generated/kotlin --javascript_out=./generated/javascript --grpc +foryc compiler/examples/service.fdl --java_out=./generated/java --python_out=./generated/python --go_out=./generated/go --rust_out=./generated/rust --csharp_out=./generated/csharp --scala_out=./generated/scala --kotlin_out=./generated/kotlin --javascript_out=./generated/javascript --swift_out=./generated/swift --grpc ``` The generated gRPC service code uses Fory to serialize request and response -payloads. Java output imports grpc-java APIs, Python output defaults to +bodies. Java output imports grpc-java APIs, Python output defaults to `grpc.aio`, Go output imports grpc-go, Rust output imports `tonic` and `bytes`, Scala output imports grpc-java APIs, and Kotlin output imports grpc-java and grpc-kotlin APIs and uses coroutine stubs. C# output imports `Grpc.Core.Api` types and can be hosted with normal .NET gRPC packages such as `Grpc.AspNetCore` or called through `Grpc.Net.Client`. JavaScript output imports `@grpc/grpc-js`. +Swift output targets grpc-swift 1.x and emits `async`/`await` providers and +clients alongside `EventLoopFuture` providers. Applications that compile or run those generated service files must provide their own gRPC dependencies. Fory packages do not add a hard gRPC dependency for this feature. @@ -424,6 +426,8 @@ generated/ - Each schema includes a schema-file module owner and `toBytes`/`fromBytes` helpers - Imported schemas are installed transitively by generated module helpers +- With `--grpc`, one `Grpc.swift` companion per service is generated + next to the model, targeting grpc-swift 1.x ### Dart diff --git a/docs/guide/swift/grpc-support.md b/docs/guide/swift/grpc-support.md new file mode 100644 index 0000000000..5118ed371e --- /dev/null +++ b/docs/guide/swift/grpc-support.md @@ -0,0 +1,202 @@ +--- +title: gRPC Support +sidebar_position: 12 +id: grpc_support +license: | + Licensed to the Apache Software Foundation (ASF) under one or more + contributor license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright ownership. + The ASF licenses this file to You under the Apache License, Version 2.0 + (the "License"); you may not use this file except in compliance with + the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--- + +Fory can generate Swift gRPC service companions for schemas that define +services. The companion provides the usual gRPC service providers, clients, +method descriptors, and service metadata, while request and response objects are +serialized with Fory instead of protobuf. + +Use this mode when both RPC peers are generated from the same Fory IDL, protobuf +IDL, or FlatBuffers IDL and both sides expect Fory-encoded message bodies. Use +normal protobuf gRPC generation for APIs that must be consumed by generic +protobuf clients, reflection tools, or components that expect protobuf bytes. + +The companion targets [grpc-swift](https://github.com/grpc/grpc-swift) 1.x. That +line keeps the same platform floor as the Fory Swift package (macOS 13, iOS 16); +grpc-swift 2.x requires a newer floor. + +## Add Dependencies + +The `Fory` package does not depend on grpc-swift. Add grpc-swift in the package +that compiles or runs the generated companions: + +```swift +// Package.swift +dependencies: [ + .package(url: "https://github.com/apache/fory.git", from: "1.2.0"), + .package(url: "https://github.com/grpc/grpc-swift.git", from: "1.23.0"), +], +targets: [ + .target( + name: "App", + dependencies: [ + .product(name: "Fory", package: "fory"), + .product(name: "GRPC", package: "grpc-swift"), + ] + ) +] +``` + +## Define a Service + +Service definitions can come from Fory IDL, protobuf IDL, or FlatBuffers +`rpc_service` definitions. A Fory IDL service looks like this: + +```protobuf +package demo.greeter; + +message HelloRequest { + string name = 1; +} + +message HelloReply { + string reply = 1; +} + +service Greeter { + rpc SayHello (HelloRequest) returns (HelloReply); +} +``` + +Generate Swift model and gRPC companion code with `--grpc`: + +```bash +foryc service.fdl --swift_out=./Sources/App --grpc +``` + +For this schema the Swift generator emits: + +| File | Purpose | +| -------------------------------- | -------------------------------------------- | +| `demo/greeter/greeter.swift` | Fory model types and the `ForyModule` helper | +| `demo/greeter/GreeterGrpc.swift` | gRPC providers, client, and service metadata | + +Generated gRPC symbols are prefixed with the package, so the schema above emits +`Demo_Greeter_GreeterAsyncProvider`, `Demo_Greeter_GreeterAsyncClient`, and +`Demo_Greeter_GreeterProvider`. A schema with no package drops the prefix +(`GreeterAsyncProvider`). + +## Implement a Server + +Conform a type to the generated `async`/`await` provider and host it with a +normal grpc-swift `Server`: + +```swift +import Fory +import GRPC +import NIOPosix + +final class GreeterService: Demo_Greeter_GreeterAsyncProvider { + func sayHello( + request: Demo.Greeter.HelloRequest, + context: GRPCAsyncServerCallContext + ) async throws -> Demo.Greeter.HelloReply { + Demo.Greeter.HelloReply(reply: "Hello, " + request.name) + } +} + +let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) +let server = try await Server.insecure(group: group) + .withServiceProviders([GreeterService()]) + .bind(host: "127.0.0.1", port: 1234) + .get() +``` + +Request and response types are registered by the generated schema module that +the companion uses, so server code does not register serializers by hand. An +`EventLoopFuture`-based `Demo_Greeter_GreeterProvider` is also emitted for +servers that do not use `async`/`await`. + +## Create a Client + +Use the generated async client over a grpc-swift channel: + +```swift +import Fory +import GRPC +import NIOPosix + +let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) +let channel = try GRPCChannelPool.with( + target: .host("127.0.0.1", port: 1234), + transportSecurity: .plaintext, + eventLoopGroup: group) + +let client = Demo_Greeter_GreeterAsyncClient(channel: channel) +let reply = try await client.sayHello(Demo.Greeter.HelloRequest(name: "Fory")) +print(reply.reply) +``` + +## Streaming RPCs + +Fory service definitions can use the four gRPC streaming shapes: + +```protobuf +service Greeter { + rpc SayHello (HelloRequest) returns (HelloReply); + rpc LotsOfReplies (HelloRequest) returns (stream HelloReply); + rpc LotsOfGreetings (stream HelloRequest) returns (HelloReply); + rpc BidiHello (stream HelloRequest) returns (stream HelloReply); +} +``` + +Streaming methods present clean request and response types. The provider receives +a response writer (`send(_:)`) for server output and an `AsyncSequence` for client +input; the client returns an `AsyncSequence` of responses for server-streamed +replies: + +```swift +// Server side +func lotsOfReplies( + request: Demo.Greeter.HelloRequest, + responseStream: Demo_Greeter_GreeterAsyncResponseStream, + context: GRPCAsyncServerCallContext +) async throws { + try await responseStream.send(Demo.Greeter.HelloReply(reply: "Hi " + request.name)) +} + +// Client side +for try await reply in client.lotsOfReplies(Demo.Greeter.HelloRequest(name: "Fory")) { + print(reply.reply) +} +``` + +## gRPC Runtime Behavior + +Generated companions carry Fory-encoded bytes inside a private `GRPCPayload` +wrapper that serializes through the schema module's shared `Fory` instance. +Imported request and response types resolve to their own namespace and are +registered transitively through the owning module, so a service that crosses an +import boundary works without extra registration. + +## Troubleshooting + +### Missing grpc-swift Types + +If the build cannot find `GRPCAsyncServerCallContext`, `Server`, or +`GRPCChannelPool`, add the grpc-swift dependency and the `GRPC` product to the +target that compiles the generated companion. + +### Protobuf Clients Cannot Decode the Service + +Generated companions exchange Fory-encoded bodies, not protobuf bytes. A generic +protobuf client cannot decode them. Both peers must be generated from the same +Fory IDL and use the generated Fory companions. diff --git a/docs/guide/swift/index.md b/docs/guide/swift/index.md index 92dcb709e4..15ca8504bb 100644 --- a/docs/guide/swift/index.md +++ b/docs/guide/swift/index.md @@ -59,6 +59,7 @@ targets: [ - [Shared and Circular References](references.md) - [Polymorphism and Dynamic Types](polymorphism.md) - [Schema Evolution](schema-evolution.md) +- [gRPC Support](grpc-support.md) - [Troubleshooting](troubleshooting.md) ## Quick Example From 034861b2f9b4a580ba7e199c35557489c81888cf Mon Sep 17 00:00:00 2001 From: yash Date: Sat, 20 Jun 2026 11:18:40 +0530 Subject: [PATCH 07/15] style(compiler): wrap long Swift gRPC handler closures Break the streaming handler closures across lines so generated companions stay under the swiftlint line-length limit even with long package-qualified names. --- .../generators/services/swift.py | 48 ++++++++++++------- 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/compiler/fory_compiler/generators/services/swift.py b/compiler/fory_compiler/generators/services/swift.py index 47ab2b6ac7..83116ddc6d 100644 --- a/compiler/fory_compiler/generators/services/swift.py +++ b/compiler/fory_compiler/generators/services/swift.py @@ -340,11 +340,13 @@ def _provider_handler_case(self, base: str, method: RpcMethod) -> List[str]: f"self.{name}(request: req.value, context: ctx).map {{ ForyMessage($0) }} }})" ) elif mode is StreamingMode.SERVER_STREAMING: - head.append( - f" userFunction: {{ req, ctx in self.{name}(" - f"request: req.value, context: {base}" - f"StreamingResponseContext(base: ctx)) }})" - ) + head += [ + " userFunction: { req, ctx in", + f" self.{name}(", + " request: req.value,", + f" context: {base}StreamingResponseContext(base: ctx))", + " })", + ] elif mode is StreamingMode.CLIENT_STREAMING: head.extend( self._client_stream_observer(base, name, req, "UnaryResponseContext") @@ -459,21 +461,31 @@ def _async_provider_handler_case(self, base: str, method: RpcMethod) -> List[str f"try await self.{name}(request: $0.value, context: $1)) }})" ) elif mode is StreamingMode.SERVER_STREAMING: - head.append( - f" wrapping: {{ try await self.{name}(request: $0.value, " - f"responseStream: {base}AsyncResponseStream(base: $1), context: $2) }})" - ) + head += [ + " wrapping: {", + f" try await self.{name}(", + " request: $0.value,", + f" responseStream: {base}AsyncResponseStream(base: $1),", + " context: $2)", + " })", + ] elif mode is StreamingMode.CLIENT_STREAMING: - head.append( - f" wrapping: {{ ForyMessage(try await self.{name}(" - f"requestStream: {base}AsyncRequestStream(base: $0), context: $1)) }})" - ) + head += [ + " wrapping: {", + f" ForyMessage(try await self.{name}(", + f" requestStream: {base}AsyncRequestStream(base: $0),", + " context: $1))", + " })", + ] else: - head.append( - f" wrapping: {{ try await self.{name}(" - f"requestStream: {base}AsyncRequestStream(base: $0), " - f"responseStream: {base}AsyncResponseStream(base: $1), context: $2) }})" - ) + head += [ + " wrapping: {", + f" try await self.{name}(", + f" requestStream: {base}AsyncRequestStream(base: $0),", + f" responseStream: {base}AsyncResponseStream(base: $1),", + " context: $2)", + " })", + ] return head def _async_client(self, base: str, service: Service) -> List[str]: From 7f3d888bd9da2ed6636e85bd522ecf6fc32e2d0f Mon Sep 17 00:00:00 2001 From: yash Date: Sat, 20 Jun 2026 13:05:56 +0530 Subject: [PATCH 08/15] style(compiler): make Swift gRPC companions swiftlint-clean Put handler braces on the declaration line, give each async parameter its own aligned line, name the unwrapped stream value, and scope a type_name disable around the package-prefixed symbols so swiftlint reports no violations. --- .../generators/services/swift.py | 48 ++++++++++++------- 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/compiler/fory_compiler/generators/services/swift.py b/compiler/fory_compiler/generators/services/swift.py index 83116ddc6d..dcd70bfa31 100644 --- a/compiler/fory_compiler/generators/services/swift.py +++ b/compiler/fory_compiler/generators/services/swift.py @@ -102,6 +102,9 @@ def _generate_swift_service(self, service: Service) -> GeneratedFile: lines: List[str] = [] lines.append(self.get_license_header("//")) lines.append("") + # gRPC symbols are package-prefixed with underscores, matching grpc-swift. + lines.append("// swiftlint:disable type_name") + lines.append("") lines.append("import Foundation") lines.append("import GRPC") lines.append("import NIOCore") @@ -119,6 +122,8 @@ def _generate_swift_service(self, service: Service) -> GeneratedFile: lines.extend(self._async_provider(base, service)) lines.append("") lines.extend(self._async_client(base, service)) + lines.append("") + lines.append("// swiftlint:enable type_name") content = "\n".join(lines).rstrip() + "\n" package_path = ( @@ -281,11 +286,7 @@ def _provider(self, base: str, service: Service) -> List[str]: f"{{ {base}Metadata.serviceDescriptor.fullName[...] }}" ) lines.append("") - lines.append( - " public func handle(method name: Substring, context: CallHandlerContext)" - ) - lines.append(" -> GRPCServerHandlerProtocol?") - lines.append(" {") + lines.extend(self._handle_signature()) lines.append(" switch name {") for method in service.methods: lines.extend(self._provider_handler_case(base, method)) @@ -295,6 +296,14 @@ def _provider(self, base: str, service: Service) -> List[str]: lines.append("}") return lines + def _handle_signature(self) -> List[str]: + return [ + " public func handle(", + " method name: Substring,", + " context: CallHandlerContext", + " ) -> GRPCServerHandlerProtocol? {", + ] + def _provider_requirement(self, base: str, method: RpcMethod) -> List[str]: name = self._swift_grpc_method_name(method) req = self._request_type(method) @@ -368,7 +377,8 @@ def _client_stream_observer( ".map { observer in", f" {{ (event: StreamEvent>) in", " switch event {", - " case .message(let m): observer(.message(m.value))", + " case .message(let wrapped): " + "observer(.message(wrapped.value))", " case .end: observer(.end)", " @unknown default: break", " }", @@ -401,11 +411,7 @@ def _async_provider(self, base: str, service: Service) -> List[str]: f"{{ {base}Metadata.serviceDescriptor.fullName[...] }}" ) lines.append("") - lines.append( - " public func handle(method name: Substring, context: CallHandlerContext)" - ) - lines.append(" -> GRPCServerHandlerProtocol?") - lines.append(" {") + lines.extend(self._handle_signature()) lines.append(" switch name {") for method in service.methods: lines.extend(self._async_provider_handler_case(base, method)) @@ -427,19 +433,25 @@ def _async_provider_requirement(self, base: str, method: RpcMethod) -> List[str] ] if mode is StreamingMode.SERVER_STREAMING: return [ - f" func {name}(request: {req}, " - f"responseStream: {base}AsyncResponseStream<{res}>,", - " context: GRPCAsyncServerCallContext) async throws", + f" func {name}(", + f" request: {req},", + f" responseStream: {base}AsyncResponseStream<{res}>,", + " context: GRPCAsyncServerCallContext", + " ) async throws", ] if mode is StreamingMode.CLIENT_STREAMING: return [ - f" func {name}(requestStream: {base}AsyncRequestStream<{req}>,", - f" context: GRPCAsyncServerCallContext) async throws -> {res}", + f" func {name}(", + f" requestStream: {base}AsyncRequestStream<{req}>,", + " context: GRPCAsyncServerCallContext", + f" ) async throws -> {res}", ] return [ - f" func {name}(requestStream: {base}AsyncRequestStream<{req}>,", + f" func {name}(", + f" requestStream: {base}AsyncRequestStream<{req}>,", f" responseStream: {base}AsyncResponseStream<{res}>,", - " context: GRPCAsyncServerCallContext) async throws", + " context: GRPCAsyncServerCallContext", + " ) async throws", ] def _async_provider_handler_case(self, base: str, method: RpcMethod) -> List[str]: From 1ab4cf8867aaf5a4280edc7e30d0a7f17594edf0 Mon Sep 17 00:00:00 2001 From: yash Date: Sat, 20 Jun 2026 15:42:11 +0530 Subject: [PATCH 09/15] test(compiler): document the Swift common-root package limit Schemas that share a top-level package component make the model generator emit a duplicate root enum, which the Swift compiler rejects in one module. Pin it with a strict xfail fixture and a docs note pointing at disjoint packages. --- .../tests/test_service_codegen.py | 83 +++++++++++++++++++ docs/guide/swift/grpc-support.md | 10 +++ 2 files changed, 93 insertions(+) diff --git a/compiler/fory_compiler/tests/test_service_codegen.py b/compiler/fory_compiler/tests/test_service_codegen.py index a8553b8f17..0eca6257bf 100644 --- a/compiler/fory_compiler/tests/test_service_codegen.py +++ b/compiler/fory_compiler/tests/test_service_codegen.py @@ -2482,6 +2482,89 @@ def test_swift_grpc_swiftpm_fixture(tmp_path: Path): assert "GENERATED OK" in result.stdout +@pytest.mark.skipif(shutil.which("swift") is None, reason="swift not installed") +@pytest.mark.xfail( + strict=True, + reason=( + "Pre-existing model-generator limitation, not gRPC specific: two schemas " + "that share a top-level package component (demo.shared and demo.greeter) " + "each emit `public enum Demo`, which is an invalid redeclaration when both " + "compile into one Swift module. gRPC companions sit on the model and " + "inherit it. Disjoint top-level packages work (test_swift_grpc_swiftpm_fixture)." + ), +) +def test_swift_grpc_common_root_package(tmp_path: Path): + repo_root = Path(__file__).resolve().parents[3] + common = tmp_path / "common.fdl" + common.write_text( + dedent( + """ + package demo.shared; + + message SharedRequest { string name = 1; } + """ + ) + ) + main = tmp_path / "main.fdl" + main.write_text( + dedent( + """ + package demo.greeter; + + import "common.fdl"; + + message LocalRequest { string name = 1; } + + service Greeter { + rpc Unary (LocalRequest) returns (LocalRequest); + } + """ + ) + ) + pkg = tmp_path / "pkg" + app = pkg / "Sources" / "App" + app.mkdir(parents=True) + assert foryc_main(["--swift_out", str(app), "--grpc", str(common), str(main)]) == 0 + (app / "main.swift").write_text("import Foundation\n") + (pkg / "Package.swift").write_text( + dedent( + f""" + // swift-tools-version:5.9 + import PackageDescription + let package = Package( + name: "App", + platforms: [.macOS(.v13)], + dependencies: [ + .package(url: "https://github.com/grpc/grpc-swift.git", exact: "1.24.2"), + .package(path: "{repo_root / "swift"}"), + ], + targets: [ + .executableTarget( + name: "App", + dependencies: [ + .product(name: "GRPC", package: "grpc-swift"), + .product(name: "Fory", package: "swift"), + ], + path: "Sources/App" + ) + ] + ) + """ + ).strip() + ) + result = subprocess.run( + ["swift", "build"], + cwd=pkg, + text=True, + capture_output=True, + timeout=900, + check=False, + ) + # Expected to fail today: `invalid redeclaration of 'Demo'`. strict xfail + # flags us if the model generator ever stops sharing the root enum. + assert result.returncode == 0, result.stdout + result.stderr + + def test_generated_message_signatures(): schema = parse_fdl(_GREETER_WITH_SERVICE) java_files = generate_files(schema, JavaGenerator) diff --git a/docs/guide/swift/grpc-support.md b/docs/guide/swift/grpc-support.md index 5118ed371e..6cbd12e91c 100644 --- a/docs/guide/swift/grpc-support.md +++ b/docs/guide/swift/grpc-support.md @@ -187,6 +187,16 @@ Imported request and response types resolve to their own namespace and are registered transitively through the owning module, so a service that crosses an import boundary works without extra registration. +## Known Limitations + +Swift models put each package under a nested `enum` namespace, so two schemas that +share a top-level package component (for example `demo.shared` and `demo.greeter`) +both emit `public enum Demo`. Swift treats that as an invalid redeclaration when +both compile into one module. This is a model-generation behavior, not specific to +gRPC, but it also affects a service that imports across such packages. Give the +schemas disjoint top-level packages (for example `shared.models` and +`greeter.api`), or compile them as separate Swift modules. + ## Troubleshooting ### Missing grpc-swift Types From f746c2c87c641b1bcb264fbda05d1f8b8fa85c68 Mon Sep 17 00:00:00 2001 From: yash Date: Sat, 20 Jun 2026 17:58:49 +0530 Subject: [PATCH 10/15] fix(compiler): use a per-thread Fory in Swift gRPC marshaller The Swift Fory instance is single-threaded, but gRPC drives the marshaller from many threads at once, so sharing one instance races. Build one Fory per thread from the module config and registrations, and fire 200 parallel calls in the fixture to exercise it. --- .../generators/services/swift.py | 19 ++++++++++++++++--- .../tests/test_service_codegen.py | 14 ++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/compiler/fory_compiler/generators/services/swift.py b/compiler/fory_compiler/generators/services/swift.py index dcd70bfa31..9408d93660 100644 --- a/compiler/fory_compiler/generators/services/swift.py +++ b/compiler/fory_compiler/generators/services/swift.py @@ -134,18 +134,31 @@ def _generate_swift_service(self, service: Service) -> GeneratedFile: return GeneratedFile(path=path, content=content) def _marshaller(self, module: str) -> List[str]: - # NIOCore.ByteBuffer is qualified because `import Fory` also exposes one. + # The Swift Fory instance is single-threaded, so keep one per thread. return [ + "private enum ForyRuntime {", + f' private static let key = "org.apache.fory.grpc.{module}"', + " static func fory() throws -> Fory {", + " let storage = Thread.current.threadDictionary", + " if let existing = storage[key] as? Fory { return existing }", + f" let local = Fory(config: {module}.getFory().config)", + f" try {module}.install(local)", + " storage[key] = local", + " return local", + " }", + "}", + "", "// Internal Fory wire wrapper for gRPC request and response messages.", + "// NIOCore.ByteBuffer is qualified because `import Fory` also exposes one.", "private struct ForyMessage: GRPCPayload {", " var value: Value", " init(_ value: Value) { self.value = value }", " init(serializedByteBuffer buffer: inout NIOCore.ByteBuffer) throws {", " let bytes = buffer.readBytes(length: buffer.readableBytes) ?? []", - f" self.value = try {module}.getFory().deserialize(Data(bytes))", + " self.value = try ForyRuntime.fory().deserialize(Data(bytes))", " }", " func serialize(into buffer: inout NIOCore.ByteBuffer) throws {", - f" buffer.writeBytes(try {module}.getFory().serialize(value))", + " buffer.writeBytes(try ForyRuntime.fory().serialize(value))", " }", "}", ] diff --git a/compiler/fory_compiler/tests/test_service_codegen.py b/compiler/fory_compiler/tests/test_service_codegen.py index 0eca6257bf..405aadc759 100644 --- a/compiler/fory_compiler/tests/test_service_codegen.py +++ b/compiler/fory_compiler/tests/test_service_codegen.py @@ -3351,5 +3351,19 @@ def test_rust_grpc_rejects_unsafe_refs(): var bd: [String] = [] for try await m in client.bidi(locals(["m", "n"])) { bd.append(m.text) } precondition(bd == ["echo:m", "echo:n"]) + +// Fire many parallel unary calls to exercise the per-thread Fory marshaller. +let burst = try await withThrowingTaskGroup(of: String.self) { group -> Int in + for i in 0..<200 { + group.addTask { try await client.unary(Greeter.Api.LocalRequest(name: "c\(i)")).text } + } + var count = 0 + for try await text in group { + precondition(text.hasPrefix("hi c")) + count += 1 + } + return count +} +precondition(burst == 200) print("GENERATED OK") """ From 00f26573ee46eda16592ed3a559b4d15db438aae Mon Sep 17 00:00:00 2001 From: yash Date: Sun, 21 Jun 2026 10:23:34 +0530 Subject: [PATCH 11/15] docs: note Swift gRPC client and interceptor limits Record that the generated client is async only and that interceptors are not emitted, both because grpc-swift types them on the internal Fory wrapper, and describe the per-thread marshalling. --- docs/guide/swift/grpc-support.md | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/guide/swift/grpc-support.md b/docs/guide/swift/grpc-support.md index 6cbd12e91c..7125e1dc1a 100644 --- a/docs/guide/swift/grpc-support.md +++ b/docs/guide/swift/grpc-support.md @@ -182,13 +182,25 @@ for try await reply in client.lotsOfReplies(Demo.Greeter.HelloRequest(name: "For ## gRPC Runtime Behavior Generated companions carry Fory-encoded bytes inside a private `GRPCPayload` -wrapper that serializes through the schema module's shared `Fory` instance. -Imported request and response types resolve to their own namespace and are -registered transitively through the owning module, so a service that crosses an -import boundary works without extra registration. +wrapper. The Swift `Fory` instance is single-threaded, so the wrapper uses one +`Fory` per thread, built from the schema module's configuration and registrations, +which makes concurrent RPCs safe without sharing a single instance. Imported +request and response types resolve to their own namespace and are registered +transitively through the owning module, so a service that crosses an import +boundary works without extra registration. ## Known Limitations +The generated client is async/await only. grpc-swift's `EventLoopFuture` client +returns call objects parameterized by the on-the-wire message type, which would +expose the internal Fory wrapper, so it is not emitted. Both providers (async and +`EventLoopFuture`) are generated. + +Interceptors are not generated. grpc-swift interceptors are typed on the +on-the-wire message, which is the internal Fory wrapper; emitting interceptor +hooks would expose that wrapper. Use a custom channel or server configuration for +cross-cutting concerns instead. + Swift models put each package under a nested `enum` namespace, so two schemas that share a top-level package component (for example `demo.shared` and `demo.greeter`) both emit `public enum Demo`. Swift treats that as an invalid redeclaration when From 3c93b3ea3b723e253745d54c7b6651dd9a1d495f Mon Sep 17 00:00:00 2001 From: yash Date: Sun, 21 Jun 2026 12:11:07 +0530 Subject: [PATCH 12/15] test(compiler): prove Swift marshaller thread-safety under TSan Name the wire wrapper per service so it is reachable, then drive it from 2000 parallel threads under ThreadSanitizer, asserting no data race and that the per-thread Fory stays wire-compatible with the module's shared instance. Against a shared instance TSan flags a race in the type resolver. --- .../generators/services/swift.py | 54 ++++----- .../tests/test_service_codegen.py | 111 +++++++++++++++++- 2 files changed, 137 insertions(+), 28 deletions(-) diff --git a/compiler/fory_compiler/generators/services/swift.py b/compiler/fory_compiler/generators/services/swift.py index 9408d93660..40b4403f06 100644 --- a/compiler/fory_compiler/generators/services/swift.py +++ b/compiler/fory_compiler/generators/services/swift.py @@ -112,7 +112,7 @@ def _generate_swift_service(self, service: Service) -> GeneratedFile: lines.append("") if methods: - lines.extend(self._marshaller(module)) + lines.extend(self._marshaller(base, module)) lines.append("") lines.extend(self._metadata(base, service)) lines.append("") @@ -133,7 +133,7 @@ def _generate_swift_service(self, service: Service) -> GeneratedFile: path = f"{package_path}/{file_name}" if package_path else file_name return GeneratedFile(path=path, content=content) - def _marshaller(self, module: str) -> List[str]: + def _marshaller(self, base: str, module: str) -> List[str]: # The Swift Fory instance is single-threaded, so keep one per thread. return [ "private enum ForyRuntime {", @@ -150,7 +150,7 @@ def _marshaller(self, module: str) -> List[str]: "", "// Internal Fory wire wrapper for gRPC request and response messages.", "// NIOCore.ByteBuffer is qualified because `import Fory` also exposes one.", - "private struct ForyMessage: GRPCPayload {", + f"struct {base}Message: GRPCPayload {{", " var value: Value", " init(_ value: Value) { self.value = value }", " init(serializedByteBuffer buffer: inout NIOCore.ByteBuffer) throws {", @@ -220,11 +220,11 @@ def _adapters(self, base: str, modes: Set[StreamingMode]) -> List[str]: def _streaming_response_context(self, base: str) -> List[str]: return [ f"public struct {base}StreamingResponseContext {{", - " fileprivate let base: StreamingResponseCallContext>", + f" fileprivate let base: StreamingResponseCallContext<{base}Message>", " public var eventLoop: EventLoop { base.eventLoop }", " @discardableResult", " public func sendResponse(_ response: Response) -> EventLoopFuture {", - " base.sendResponse(ForyMessage(response))", + f" base.sendResponse({base}Message(response))", " }", "}", ] @@ -232,10 +232,10 @@ def _streaming_response_context(self, base: str) -> List[str]: def _unary_response_context(self, base: str) -> List[str]: return [ f"public struct {base}UnaryResponseContext {{", - " fileprivate let base: UnaryResponseCallContext>", + f" fileprivate let base: UnaryResponseCallContext<{base}Message>", " public var eventLoop: EventLoop { base.eventLoop }", " public func respond(_ response: Response) {", - " base.responsePromise.succeed(ForyMessage(response))", + f" base.responsePromise.succeed({base}Message(response))", " }", "}", ] @@ -244,9 +244,9 @@ def _async_response_stream(self, base: str) -> List[str]: return [ _ASYNC_AVAILABLE, f"public struct {base}AsyncResponseStream {{", - " fileprivate let base: GRPCAsyncResponseStreamWriter>", + f" fileprivate let base: GRPCAsyncResponseStreamWriter<{base}Message>", " public func send(_ response: Response) async throws {", - " try await base.send(ForyMessage(response))", + f" try await base.send({base}Message(response))", " }", "}", ] @@ -256,9 +256,9 @@ def _async_request_stream(self, base: str) -> List[str]: _ASYNC_AVAILABLE, f"public struct {base}AsyncRequestStream: AsyncSequence {{", " public typealias Element = Request", - " fileprivate let base: GRPCAsyncRequestStream>", + f" fileprivate let base: GRPCAsyncRequestStream<{base}Message>", " public struct AsyncIterator: AsyncIteratorProtocol {", - " fileprivate var base: GRPCAsyncRequestStream>.AsyncIterator", + f" fileprivate var base: GRPCAsyncRequestStream<{base}Message>.AsyncIterator", " public mutating func next() async throws -> Request? {", " try await base.next()?.value", " }", @@ -274,9 +274,9 @@ def _client_response_stream(self, base: str) -> List[str]: _ASYNC_AVAILABLE, f"public struct {base}ResponseStream: AsyncSequence {{", " public typealias Element = Response", - " fileprivate let base: GRPCAsyncResponseStream>", + f" fileprivate let base: GRPCAsyncResponseStream<{base}Message>", " public struct AsyncIterator: AsyncIteratorProtocol {", - " fileprivate var base: GRPCAsyncResponseStream>.AsyncIterator", + f" fileprivate var base: GRPCAsyncResponseStream<{base}Message>.AsyncIterator", " public mutating func next() async throws -> Response? {", " try await base.next()?.value", " }", @@ -352,14 +352,14 @@ def _provider_handler_case(self, base: str, method: RpcMethod) -> List[str]: f' case "{method.name}":', f" return {self._server_handler(mode)}(", " context: context,", - f" requestDeserializer: GRPCPayloadDeserializer>(),", - f" responseSerializer: GRPCPayloadSerializer>(),", + f" requestDeserializer: GRPCPayloadDeserializer<{base}Message<{req}>>(),", + f" responseSerializer: GRPCPayloadSerializer<{base}Message<{res}>>(),", " interceptors: [],", ] if mode is StreamingMode.UNARY: head.append( f" userFunction: {{ req, ctx in " - f"self.{name}(request: req.value, context: ctx).map {{ ForyMessage($0) }} }})" + f"self.{name}(request: req.value, context: ctx).map {{ {base}Message($0) }} }})" ) elif mode is StreamingMode.SERVER_STREAMING: head += [ @@ -388,7 +388,7 @@ def _client_stream_observer( " observerFactory: { ctx in", f" self.{name}(context: {base}{ctx_kind}(base: ctx))" ".map { observer in", - f" {{ (event: StreamEvent>) in", + f" {{ (event: StreamEvent<{base}Message<{req}>>) in", " switch event {", " case .message(let wrapped): " "observer(.message(wrapped.value))", @@ -476,13 +476,13 @@ def _async_provider_handler_case(self, base: str, method: RpcMethod) -> List[str f' case "{method.name}":', " return GRPCAsyncServerHandler(", " context: context,", - f" requestDeserializer: GRPCPayloadDeserializer>(),", - f" responseSerializer: GRPCPayloadSerializer>(),", + f" requestDeserializer: GRPCPayloadDeserializer<{base}Message<{req}>>(),", + f" responseSerializer: GRPCPayloadSerializer<{base}Message<{res}>>(),", " interceptors: [],", ] if mode is StreamingMode.UNARY: head.append( - f" wrapping: {{ ForyMessage(" + f" wrapping: {{ {base}Message(" f"try await self.{name}(request: $0.value, context: $1)) }})" ) elif mode is StreamingMode.SERVER_STREAMING: @@ -497,7 +497,7 @@ def _async_provider_handler_case(self, base: str, method: RpcMethod) -> List[str elif mode is StreamingMode.CLIENT_STREAMING: head += [ " wrapping: {", - f" ForyMessage(try await self.{name}(", + f" {base}Message(try await self.{name}(", f" requestStream: {base}AsyncRequestStream(base: $0),", " context: $1))", " })", @@ -539,9 +539,9 @@ def _async_client_method(self, base: str, method: RpcMethod) -> List[str]: if mode is StreamingMode.UNARY: return [ f" public func {name}(_ request: {req}) async throws -> {res} {{", - f" let response: ForyMessage<{res}> = try await performAsyncUnaryCall(", + f" let response: {base}Message<{res}> = try await performAsyncUnaryCall(", f" path: {path},", - " request: ForyMessage(request), callOptions: defaultCallOptions)", + f" request: {base}Message(request), callOptions: defaultCallOptions)", " return response.value", " }", ] @@ -550,7 +550,7 @@ def _async_client_method(self, base: str, method: RpcMethod) -> List[str]: f" public func {name}(_ request: {req}) -> {base}ResponseStream<{res}> {{", f" {base}ResponseStream(base: performAsyncServerStreamingCall(", f" path: {path},", - " request: ForyMessage(request), callOptions: defaultCallOptions))", + f" request: {base}Message(request), callOptions: defaultCallOptions))", " }", ] if mode is StreamingMode.CLIENT_STREAMING: @@ -558,9 +558,9 @@ def _async_client_method(self, base: str, method: RpcMethod) -> List[str]: f" public func {name}(_ requests: S)" f" async throws -> {res}", f" where S.Element == {req} {{", - f" let response: ForyMessage<{res}> = try await performAsyncClientStreamingCall(", + f" let response: {base}Message<{res}> = try await performAsyncClientStreamingCall(", f" path: {path},", - " requests: requests.map { ForyMessage($0) }, callOptions: defaultCallOptions)", + f" requests: requests.map {{ {base}Message($0) }}, callOptions: defaultCallOptions)", " return response.value", " }", ] @@ -570,6 +570,6 @@ def _async_client_method(self, base: str, method: RpcMethod) -> List[str]: f" where S.Element == {req} {{", f" {base}ResponseStream(base: performAsyncBidirectionalStreamingCall(", f" path: {path},", - " requests: requests.map { ForyMessage($0) }, callOptions: defaultCallOptions))", + f" requests: requests.map {{ {base}Message($0) }}, callOptions: defaultCallOptions))", " }", ] diff --git a/compiler/fory_compiler/tests/test_service_codegen.py b/compiler/fory_compiler/tests/test_service_codegen.py index 405aadc759..d6ef0d33ff 100644 --- a/compiler/fory_compiler/tests/test_service_codegen.py +++ b/compiler/fory_compiler/tests/test_service_codegen.py @@ -18,6 +18,7 @@ """Codegen smoke tests for schemas that contain service definitions.""" from pathlib import Path +import os import shutil import subprocess from textwrap import dedent @@ -975,7 +976,11 @@ def test_swift_grpc_fory_marshaller(): files = generate_service_files(schema, SwiftGenerator) assert set(files) == {"demo/greeter/GreeterGrpc.swift"} content = files["demo/greeter/GreeterGrpc.swift"] - assert "private struct ForyMessage: GRPCPayload" in content + assert ( + "struct Demo_Greeter_GreeterMessage: GRPCPayload" in content + ) + assert "enum ForyRuntime {" in content + assert "Thread.current.threadDictionary" in content assert "Demo.Greeter.ForyModule.getFory()" in content assert "enum Demo_Greeter_GreeterMetadata" in content assert 'fullName: "demo.greeter.Greeter"' in content @@ -2565,6 +2570,71 @@ def test_swift_grpc_common_root_package(tmp_path: Path): assert result.returncode == 0, result.stdout + result.stderr +@pytest.mark.skipif(shutil.which("swift") is None, reason="swift not installed") +def test_swift_grpc_marshaller_thread_safety(tmp_path: Path): + repo_root = Path(__file__).resolve().parents[3] + schema = tmp_path / "echo.fdl" + schema.write_text( + dedent( + """ + package demo.echo; + + message Req { string name = 1; } + message Res { string text = 1; } + + service Echoer { rpc Unary (Req) returns (Res); } + """ + ) + ) + pkg = tmp_path / "pkg" + app = pkg / "Sources" / "App" + app.mkdir(parents=True) + assert foryc_main(["--swift_out", str(app), "--grpc", str(schema)]) == 0 + (app / "main.swift").write_text(_SWIFT_GRPC_THREAD_SAFETY_PROGRAM) + (pkg / "Package.swift").write_text( + dedent( + f""" + // swift-tools-version:5.9 + import PackageDescription + let package = Package( + name: "App", + platforms: [.macOS(.v13)], + dependencies: [ + .package(url: "https://github.com/grpc/grpc-swift.git", exact: "1.24.2"), + .package(path: "{repo_root / "swift"}"), + ], + targets: [ + .executableTarget( + name: "App", + dependencies: [ + .product(name: "GRPC", package: "grpc-swift"), + .product(name: "Fory", package: "swift"), + ], + path: "Sources/App" + ) + ] + ) + """ + ).strip() + ) + # Run the marshaller from many threads under ThreadSanitizer. With the + # per-thread Fory this is race-free; against a shared instance TSan reports a + # data race and halt_on_error aborts the process. + env = dict(os.environ, TSAN_OPTIONS="halt_on_error=1") + result = subprocess.run( + ["swift", "run", "--sanitize=thread"], + cwd=pkg, + text=True, + capture_output=True, + timeout=900, + check=False, + env=env, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert "data race" not in result.stderr.lower(), result.stderr + assert "THREADSAFE OK" in result.stdout + + def test_generated_message_signatures(): schema = parse_fdl(_GREETER_WITH_SERVICE) java_files = generate_files(schema, JavaGenerator) @@ -3367,3 +3437,42 @@ def test_rust_grpc_rejects_unsafe_refs(): precondition(burst == 200) print("GENERATED OK") """ + + +_SWIFT_GRPC_THREAD_SAFETY_PROGRAM = r""" +import Foundation +import GRPC +import NIOCore +import Fory + +// Many threads drive the generated marshaller at once. +DispatchQueue.concurrentPerform(iterations: 2000) { i in + do { + let allocator = ByteBufferAllocator() + let req = Demo.Echo.Req(name: "n\(i)") + var out = allocator.buffer(capacity: 64) + try Demo_Echo_EchoerMessage(req).serialize(into: &out) + let back = try Demo_Echo_EchoerMessage(serializedByteBuffer: &out) + precondition(back.value.name == "n\(i)") + } catch { + fatalError("marshaller round-trip failed: \(error)") + } +} + +// Wire compatibility between the model's shared Fory and the per-thread marshaller. +let allocator = ByteBufferAllocator() +let probe = Demo.Echo.Req(name: "probe") +let sharedBytes = try Demo.Echo.ForyModule.getFory().serialize(probe) +var inBuf = allocator.buffer(capacity: sharedBytes.count) +inBuf.writeBytes(sharedBytes) +let fromShared = try Demo_Echo_EchoerMessage(serializedByteBuffer: &inBuf) +precondition(fromShared.value.name == "probe") + +var outBuf = allocator.buffer(capacity: 64) +try Demo_Echo_EchoerMessage(probe).serialize(into: &outBuf) +let fromMarshaller: Demo.Echo.Req = + try Demo.Echo.ForyModule.getFory().deserialize(Data(outBuf.readableBytesView)) +precondition(fromMarshaller.name == "probe") + +print("THREADSAFE OK") +""" From dd40171f523002824cbcbd229269fcd1077552b9 Mon Sep 17 00:00:00 2001 From: yash Date: Sun, 21 Jun 2026 14:47:52 +0530 Subject: [PATCH 13/15] test(compiler): gate the Swift TSan test behind FORY_SWIFT_TSAN The ThreadSanitizer build adds about three minutes and is environment sensitive, so keep it opt-in for a sanitizer or nightly job while the functional fixtures still run on every swift-capable run. --- compiler/fory_compiler/tests/test_service_codegen.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/compiler/fory_compiler/tests/test_service_codegen.py b/compiler/fory_compiler/tests/test_service_codegen.py index d6ef0d33ff..e981a8fbc6 100644 --- a/compiler/fory_compiler/tests/test_service_codegen.py +++ b/compiler/fory_compiler/tests/test_service_codegen.py @@ -2571,6 +2571,10 @@ def test_swift_grpc_common_root_package(tmp_path: Path): @pytest.mark.skipif(shutil.which("swift") is None, reason="swift not installed") +@pytest.mark.skipif( + os.environ.get("FORY_SWIFT_TSAN") != "1", + reason="ThreadSanitizer build is slow; set FORY_SWIFT_TSAN=1 (CI sanitizer job)", +) def test_swift_grpc_marshaller_thread_safety(tmp_path: Path): repo_root = Path(__file__).resolve().parents[3] schema = tmp_path / "echo.fdl" From 4922f772274df645036aa26c9795516641a7d4dc Mon Sep 17 00:00:00 2001 From: yash Date: Sun, 21 Jun 2026 16:38:19 +0530 Subject: [PATCH 14/15] feat(compiler): reserve inherited member names in Swift gRPC An rpc whose Swift name is handle, serviceName, channel, or defaultCallOptions would clash with a member the generated provider or client inherits, so fail codegen with a clear message. Cover the reserved names and nested plus imported request and response payloads. --- .../generators/services/swift.py | 15 +++++ .../tests/test_service_codegen.py | 56 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/compiler/fory_compiler/generators/services/swift.py b/compiler/fory_compiler/generators/services/swift.py index 40b4403f06..978288a055 100644 --- a/compiler/fory_compiler/generators/services/swift.py +++ b/compiler/fory_compiler/generators/services/swift.py @@ -26,6 +26,15 @@ # Availability gate matching grpc-swift's async/await APIs. _ASYNC_AVAILABLE = "@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *)" +# Members the generated provider and client expose through their protocols and +# base types; an rpc whose Swift name matches one would override or clash with it. +_SWIFT_GRPC_RESERVED_MEMBERS = { + "handle", + "serviceName", + "channel", + "defaultCallOptions", +} + class SwiftServiceMixin: """Generates Swift gRPC service companions backed by Fory serialization.""" @@ -86,6 +95,12 @@ def _check_swift_grpc_method_collisions(self, services: List[Service]) -> None: seen: Dict[str, str] = {} for method in service.methods: swift_name = self._swift_grpc_method_name(method).strip("`") + if swift_name in _SWIFT_GRPC_RESERVED_MEMBERS: + raise ValueError( + f"Swift gRPC method {service.name}.{method.name} generates " + f"{swift_name}, which collides with a generated provider or " + "client member; rename the rpc" + ) if swift_name in seen: raise ValueError( f"Swift gRPC method name collision in service {service.name}: " diff --git a/compiler/fory_compiler/tests/test_service_codegen.py b/compiler/fory_compiler/tests/test_service_codegen.py index e981a8fbc6..77974ec507 100644 --- a/compiler/fory_compiler/tests/test_service_codegen.py +++ b/compiler/fory_compiler/tests/test_service_codegen.py @@ -1082,6 +1082,62 @@ def test_swift_grpc_imported_types(tmp_path: Path): assert "Demo.Greeter.ForyModule.getFory()" in content +@pytest.mark.parametrize( + "rpc_name", ["Handle", "ServiceName", "Channel", "DefaultCallOptions"] +) +def test_swift_grpc_reserved_member_collision(rpc_name): + schema = parse_fdl( + dedent( + f""" + package demo.naming; + + message Req {{}} + message Res {{}} + + service Greeter {{ rpc {rpc_name} (Req) returns (Res); }} + """ + ) + ) + with pytest.raises( + ValueError, match="collides with a generated provider or client member" + ): + generate_service_files(schema, SwiftGenerator) + + +def test_swift_grpc_nested_and_imported_payloads(tmp_path: Path): + common = tmp_path / "common.fdl" + common.write_text( + dedent( + """ + package demo.shared; + message Outer { message Inner { string v = 1; } Inner inner = 1; } + """ + ) + ) + main = tmp_path / "main.fdl" + main.write_text( + dedent( + """ + package demo.api; + import "common.fdl"; + message Local { message Deep { string v = 1; } Deep deep = 1; } + service S { + rpc Echo (Local) returns (Outer); + rpc DeepEcho (Local.Deep) returns (Outer.Inner); + } + """ + ) + ) + schema = resolve_imports(main, [tmp_path]) + content = generate_service_files(schema, SwiftGenerator)["demo/api/SGrpc.swift"] + # Nested local and imported nested types resolve to their full namespace paths. + assert "request: Demo.Api.Local," in content + assert "EventLoopFuture" in content + assert "request: Demo.Api.Local.Deep," in content + assert "Demo.Shared.Outer.Inner" in content + assert "Demo_Api_SMessage" in content + + def test_grpc_streaming_method_shapes(): schema = parse_fdl( dedent( From 4b5278ec0e5167f623b64d75385c002329ca0d6c Mon Sep 17 00:00:00 2001 From: yash Date: Sun, 21 Jun 2026 19:41:19 +0530 Subject: [PATCH 15/15] refactor(grpc): move Swift toolchain tests into integration_tests The SwiftPM build-and-run round-trip and the ThreadSanitizer marshaller test need the Swift toolchain, so move them out of the compiler pytest suite (where they only skipped) into a SwiftPM package under integration_tests/grpc_tests/swift. The common-root package limitation stays pinned as a build-free generation check. --- .../tests/test_service_codegen.py | 363 +----------------- .../grpc_tests/swift/interop/.gitignore | 3 + .../grpc_tests/swift/interop/Package.swift | 30 ++ .../MarshallerThreadSafetyTests.swift | 61 +++ .../Tests/ForyGrpcTests/RoundTripTests.swift | 144 +++++++ 5 files changed, 252 insertions(+), 349 deletions(-) create mode 100644 integration_tests/grpc_tests/swift/interop/.gitignore create mode 100644 integration_tests/grpc_tests/swift/interop/Package.swift create mode 100644 integration_tests/grpc_tests/swift/interop/Tests/ForyGrpcTests/MarshallerThreadSafetyTests.swift create mode 100644 integration_tests/grpc_tests/swift/interop/Tests/ForyGrpcTests/RoundTripTests.swift diff --git a/compiler/fory_compiler/tests/test_service_codegen.py b/compiler/fory_compiler/tests/test_service_codegen.py index 25b51333d9..3f85fde3d1 100644 --- a/compiler/fory_compiler/tests/test_service_codegen.py +++ b/compiler/fory_compiler/tests/test_service_codegen.py @@ -18,7 +18,6 @@ """Codegen smoke tests for schemas that contain service definitions.""" from pathlib import Path -import os import shutil import subprocess from textwrap import dedent @@ -2463,239 +2462,22 @@ def test_csharp_grpc_dotnet_fixture(tmp_path: Path): assert result.returncode == 0, result.stdout + result.stderr -@pytest.mark.skipif(shutil.which("swift") is None, reason="swift not installed") -def test_swift_grpc_swiftpm_fixture(tmp_path: Path): - repo_root = Path(__file__).resolve().parents[3] - common = tmp_path / "common.fdl" - common.write_text( - dedent( - """ - package shared.models; - - message SharedRequest { string name = 1; } - message SharedReply { string text = 1; } - """ - ) - ) - main = tmp_path / "main.fdl" - main.write_text( - dedent( - """ - package greeter.api; - - import "common.fdl"; - - message LocalRequest { string name = 1; } - message LocalReply { string text = 1; } - - service Greeter { - rpc Unary (LocalRequest) returns (LocalReply); - rpc Server (LocalRequest) returns (stream LocalReply); - rpc Client (stream SharedRequest) returns (SharedReply); - rpc Bidi (stream LocalRequest) returns (stream SharedReply); - } - - service Empty {} - """ - ) - ) - pkg = tmp_path / "pkg" - app = pkg / "Sources" / "App" - app.mkdir(parents=True) - assert foryc_main(["--swift_out", str(app), "--grpc", str(common), str(main)]) == 0 - assert (app / "greeter" / "api" / "GreeterGrpc.swift").is_file() - assert (app / "greeter" / "api" / "EmptyGrpc.swift").is_file() - - (pkg / "Package.swift").write_text( - dedent( - f""" - // swift-tools-version:5.9 - import PackageDescription - let package = Package( - name: "App", - platforms: [.macOS(.v13)], - dependencies: [ - .package(url: "https://github.com/grpc/grpc-swift.git", exact: "1.24.2"), - .package(path: "{repo_root / "swift"}"), - ], - targets: [ - .executableTarget( - name: "App", - dependencies: [ - .product(name: "GRPC", package: "grpc-swift"), - .product(name: "Fory", package: "swift"), - ], - path: "Sources/App" - ) - ] - ) - """ - ).strip() - ) - (app / "main.swift").write_text(_SWIFT_GRPC_VALIDATION_PROGRAM) - - result = subprocess.run( - ["swift", "run"], - cwd=pkg, - text=True, - capture_output=True, - timeout=900, - check=False, - ) - assert result.returncode == 0, result.stdout + result.stderr - assert "GENERATED OK" in result.stdout - - -@pytest.mark.skipif(shutil.which("swift") is None, reason="swift not installed") -@pytest.mark.xfail( - strict=True, - reason=( - "Pre-existing model-generator limitation, not gRPC specific: two schemas " - "that share a top-level package component (demo.shared and demo.greeter) " - "each emit `public enum Demo`, which is an invalid redeclaration when both " - "compile into one Swift module. gRPC companions sit on the model and " - "inherit it. Disjoint top-level packages work (test_swift_grpc_swiftpm_fixture)." - ), -) -def test_swift_grpc_common_root_package(tmp_path: Path): - repo_root = Path(__file__).resolve().parents[3] - common = tmp_path / "common.fdl" - common.write_text( - dedent( - """ - package demo.shared; - - message SharedRequest { string name = 1; } - """ - ) - ) - main = tmp_path / "main.fdl" - main.write_text( - dedent( - """ - package demo.greeter; - - import "common.fdl"; - - message LocalRequest { string name = 1; } - - service Greeter { - rpc Unary (LocalRequest) returns (LocalRequest); - } - """ - ) - ) - pkg = tmp_path / "pkg" - app = pkg / "Sources" / "App" - app.mkdir(parents=True) - assert foryc_main(["--swift_out", str(app), "--grpc", str(common), str(main)]) == 0 - (app / "main.swift").write_text("import Foundation\n") - (pkg / "Package.swift").write_text( - dedent( - f""" - // swift-tools-version:5.9 - import PackageDescription - let package = Package( - name: "App", - platforms: [.macOS(.v13)], - dependencies: [ - .package(url: "https://github.com/grpc/grpc-swift.git", exact: "1.24.2"), - .package(path: "{repo_root / "swift"}"), - ], - targets: [ - .executableTarget( - name: "App", - dependencies: [ - .product(name: "GRPC", package: "grpc-swift"), - .product(name: "Fory", package: "swift"), - ], - path: "Sources/App" - ) - ] - ) - """ - ).strip() - ) - result = subprocess.run( - ["swift", "build"], - cwd=pkg, - text=True, - capture_output=True, - timeout=900, - check=False, - ) - # Expected to fail today: `invalid redeclaration of 'Demo'`. strict xfail - # flags us if the model generator ever stops sharing the root enum. - assert result.returncode == 0, result.stdout + result.stderr - - -@pytest.mark.skipif(shutil.which("swift") is None, reason="swift not installed") -@pytest.mark.skipif( - os.environ.get("FORY_SWIFT_TSAN") != "1", - reason="ThreadSanitizer build is slow; set FORY_SWIFT_TSAN=1 (CI sanitizer job)", -) -def test_swift_grpc_marshaller_thread_safety(tmp_path: Path): - repo_root = Path(__file__).resolve().parents[3] - schema = tmp_path / "echo.fdl" - schema.write_text( - dedent( - """ - package demo.echo; - - message Req { string name = 1; } - message Res { string text = 1; } - - service Echoer { rpc Unary (Req) returns (Res); } - """ - ) +def test_swift_grpc_common_root_package_collision(): + # Two schemas that share a top-level package component each emit `public enum + # Demo`, an invalid redeclaration when compiled into one Swift module. This is a + # model-generator limitation, not gRPC specific; gRPC companions inherit it, and + # the workaround is disjoint top-level packages. The build-level proof lives in + # the Swift toolchain tests under integration_tests/grpc_tests/swift. + shared = generate_files( + parse_fdl("package demo.shared;\nmessage SharedRequest { string name = 1; }\n"), + SwiftGenerator, ) - pkg = tmp_path / "pkg" - app = pkg / "Sources" / "App" - app.mkdir(parents=True) - assert foryc_main(["--swift_out", str(app), "--grpc", str(schema)]) == 0 - (app / "main.swift").write_text(_SWIFT_GRPC_THREAD_SAFETY_PROGRAM) - (pkg / "Package.swift").write_text( - dedent( - f""" - // swift-tools-version:5.9 - import PackageDescription - let package = Package( - name: "App", - platforms: [.macOS(.v13)], - dependencies: [ - .package(url: "https://github.com/grpc/grpc-swift.git", exact: "1.24.2"), - .package(path: "{repo_root / "swift"}"), - ], - targets: [ - .executableTarget( - name: "App", - dependencies: [ - .product(name: "GRPC", package: "grpc-swift"), - .product(name: "Fory", package: "swift"), - ], - path: "Sources/App" - ) - ] - ) - """ - ).strip() + greeter = generate_files( + parse_fdl("package demo.greeter;\nmessage LocalRequest { string name = 1; }\n"), + SwiftGenerator, ) - # Run the marshaller from many threads under ThreadSanitizer. With the - # per-thread Fory this is race-free; against a shared instance TSan reports a - # data race and halt_on_error aborts the process. - env = dict(os.environ, TSAN_OPTIONS="halt_on_error=1") - result = subprocess.run( - ["swift", "run", "--sanitize=thread"], - cwd=pkg, - text=True, - capture_output=True, - timeout=900, - check=False, - env=env, - ) - assert result.returncode == 0, result.stdout + result.stderr - assert "data race" not in result.stderr.lower(), result.stderr - assert "THREADSAFE OK" in result.stdout + assert "public enum Demo {" in next(iter(shared.values())) + assert "public enum Demo {" in next(iter(greeter.values())) def test_generated_message_signatures(): @@ -4052,120 +3834,3 @@ def test_dart_grpc_reserved_methods(): msg = str(excinfo.value) assert "inherited Dart member" in msg assert f"Svc.{rpc_name} -> {emitted}" in msg - -_SWIFT_GRPC_VALIDATION_PROGRAM = r""" -import Foundation -import GRPC -import NIOCore -import NIOPosix - -@available(macOS 10.15, *) -final class GreeterImpl: Greeter_Api_GreeterAsyncProvider { - func unary(request: Greeter.Api.LocalRequest, context: GRPCAsyncServerCallContext) - async throws -> Greeter.Api.LocalReply { - Greeter.Api.LocalReply(text: "hi " + request.name) - } - func server(request: Greeter.Api.LocalRequest, - responseStream: Greeter_Api_GreeterAsyncResponseStream, - context: GRPCAsyncServerCallContext) async throws { - try await responseStream.send(Greeter.Api.LocalReply(text: "a:" + request.name)) - try await responseStream.send(Greeter.Api.LocalReply(text: "b:" + request.name)) - } - func client(requestStream: Greeter_Api_GreeterAsyncRequestStream, - context: GRPCAsyncServerCallContext) async throws -> Shared.Models.SharedReply { - var names: [String] = [] - for try await r in requestStream { names.append(r.name) } - return Shared.Models.SharedReply(text: "got:" + names.joined(separator: ",")) - } - func bidi(requestStream: Greeter_Api_GreeterAsyncRequestStream, - responseStream: Greeter_Api_GreeterAsyncResponseStream, - context: GRPCAsyncServerCallContext) async throws { - for try await r in requestStream { - try await responseStream.send(Shared.Models.SharedReply(text: "echo:" + r.name)) - } - } -} - -func names(_ values: [String]) -> AsyncStream { - AsyncStream { c in for v in values { c.yield(Shared.Models.SharedRequest(name: v)) }; c.finish() } -} -func locals(_ values: [String]) -> AsyncStream { - AsyncStream { c in for v in values { c.yield(Greeter.Api.LocalRequest(name: v)) }; c.finish() } -} - -guard #available(macOS 10.15, *) else { fatalError() } -let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) -defer { try? group.syncShutdownGracefully() } -let server = try Server.insecure(group: group).withServiceProviders([GreeterImpl()]) - .bind(host: "127.0.0.1", port: 0).wait() -let port = server.channel.localAddress!.port! -let channel = try GRPCChannelPool.with(target: .host("127.0.0.1", port: port), - transportSecurity: .plaintext, eventLoopGroup: group) -defer { try? channel.close().wait() } -let client = Greeter_Api_GreeterAsyncClient(channel: channel) - -let u = try await client.unary(Greeter.Api.LocalRequest(name: "world")) -precondition(u.text == "hi world") -var ss: [String] = [] -for try await m in client.server(Greeter.Api.LocalRequest(name: "x")) { ss.append(m.text) } -precondition(ss == ["a:x", "b:x"]) -let cs = try await client.client(names(["p", "q"])) -precondition(cs.text == "got:p,q") -var bd: [String] = [] -for try await m in client.bidi(locals(["m", "n"])) { bd.append(m.text) } -precondition(bd == ["echo:m", "echo:n"]) - -// Fire many parallel unary calls to exercise the per-thread Fory marshaller. -let burst = try await withThrowingTaskGroup(of: String.self) { group -> Int in - for i in 0..<200 { - group.addTask { try await client.unary(Greeter.Api.LocalRequest(name: "c\(i)")).text } - } - var count = 0 - for try await text in group { - precondition(text.hasPrefix("hi c")) - count += 1 - } - return count -} -precondition(burst == 200) -print("GENERATED OK") -""" - - -_SWIFT_GRPC_THREAD_SAFETY_PROGRAM = r""" -import Foundation -import GRPC -import NIOCore -import Fory - -// Many threads drive the generated marshaller at once. -DispatchQueue.concurrentPerform(iterations: 2000) { i in - do { - let allocator = ByteBufferAllocator() - let req = Demo.Echo.Req(name: "n\(i)") - var out = allocator.buffer(capacity: 64) - try Demo_Echo_EchoerMessage(req).serialize(into: &out) - let back = try Demo_Echo_EchoerMessage(serializedByteBuffer: &out) - precondition(back.value.name == "n\(i)") - } catch { - fatalError("marshaller round-trip failed: \(error)") - } -} - -// Wire compatibility between the model's shared Fory and the per-thread marshaller. -let allocator = ByteBufferAllocator() -let probe = Demo.Echo.Req(name: "probe") -let sharedBytes = try Demo.Echo.ForyModule.getFory().serialize(probe) -var inBuf = allocator.buffer(capacity: sharedBytes.count) -inBuf.writeBytes(sharedBytes) -let fromShared = try Demo_Echo_EchoerMessage(serializedByteBuffer: &inBuf) -precondition(fromShared.value.name == "probe") - -var outBuf = allocator.buffer(capacity: 64) -try Demo_Echo_EchoerMessage(probe).serialize(into: &outBuf) -let fromMarshaller: Demo.Echo.Req = - try Demo.Echo.ForyModule.getFory().deserialize(Data(outBuf.readableBytesView)) -precondition(fromMarshaller.name == "probe") - -print("THREADSAFE OK") -""" diff --git a/integration_tests/grpc_tests/swift/interop/.gitignore b/integration_tests/grpc_tests/swift/interop/.gitignore new file mode 100644 index 0000000000..a720271441 --- /dev/null +++ b/integration_tests/grpc_tests/swift/interop/.gitignore @@ -0,0 +1,3 @@ +.build/ +Package.resolved +Sources/Generated/ diff --git a/integration_tests/grpc_tests/swift/interop/Package.swift b/integration_tests/grpc_tests/swift/interop/Package.swift new file mode 100644 index 0000000000..a95a41d884 --- /dev/null +++ b/integration_tests/grpc_tests/swift/interop/Package.swift @@ -0,0 +1,30 @@ +// swift-tools-version:5.9 +import PackageDescription + +let package = Package( + name: "ForyGrpcInterop", + platforms: [.macOS(.v13)], + dependencies: [ + .package(url: "https://github.com/grpc/grpc-swift.git", exact: "1.24.2"), + .package(path: "../../../../swift"), + ], + targets: [ + .target( + name: "ForyGrpcGenerated", + dependencies: [ + .product(name: "GRPC", package: "grpc-swift"), + .product(name: "Fory", package: "swift"), + ], + path: "Sources/Generated" + ), + .testTarget( + name: "ForyGrpcTests", + dependencies: [ + "ForyGrpcGenerated", + .product(name: "GRPC", package: "grpc-swift"), + .product(name: "Fory", package: "swift"), + ], + path: "Tests/ForyGrpcTests" + ), + ] +) diff --git a/integration_tests/grpc_tests/swift/interop/Tests/ForyGrpcTests/MarshallerThreadSafetyTests.swift b/integration_tests/grpc_tests/swift/interop/Tests/ForyGrpcTests/MarshallerThreadSafetyTests.swift new file mode 100644 index 0000000000..9d6f78685f --- /dev/null +++ b/integration_tests/grpc_tests/swift/interop/Tests/ForyGrpcTests/MarshallerThreadSafetyTests.swift @@ -0,0 +1,61 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import Foundation +import NIOCore +import XCTest + +@testable import ForyGrpcGenerated + +// The generated marshaller uses one Fory per thread. Run it from many threads +// (under `swift test --sanitize=thread` in CI) to prove there is no data race, +// and confirm it stays wire-compatible with the schema module's shared Fory. +final class MarshallerThreadSafetyTests: XCTestCase { + func testConcurrentRoundTrip() { + DispatchQueue.concurrentPerform(iterations: 2000) { i in + do { + let allocator = ByteBufferAllocator() + let request = GrpcFdl.GrpcFdlRequest(id: "n\(i)", count: Int32(i), payload: "p\(i)") + var buffer = allocator.buffer(capacity: 64) + try GrpcFdl_FdlGrpcServiceMessage(request).serialize(into: &buffer) + let back = try GrpcFdl_FdlGrpcServiceMessage( + serializedByteBuffer: &buffer) + XCTAssertEqual(back.value, request) + } catch { + XCTFail("marshaller round-trip failed: \(error)") + } + } + } + + func testWireCompatibleWithModuleFory() throws { + let allocator = ByteBufferAllocator() + let probe = GrpcFdl.GrpcFdlRequest(id: "probe", count: 7, payload: "x") + + let sharedBytes = try GrpcFdl.ForyModule.getFory().serialize(probe) + var inbound = allocator.buffer(capacity: sharedBytes.count) + inbound.writeBytes(sharedBytes) + let fromShared = try GrpcFdl_FdlGrpcServiceMessage( + serializedByteBuffer: &inbound) + XCTAssertEqual(fromShared.value, probe) + + var outbound = allocator.buffer(capacity: 64) + try GrpcFdl_FdlGrpcServiceMessage(probe).serialize(into: &outbound) + let fromMarshaller: GrpcFdl.GrpcFdlRequest = + try GrpcFdl.ForyModule.getFory().deserialize(Data(outbound.readableBytesView)) + XCTAssertEqual(fromMarshaller, probe) + } +} diff --git a/integration_tests/grpc_tests/swift/interop/Tests/ForyGrpcTests/RoundTripTests.swift b/integration_tests/grpc_tests/swift/interop/Tests/ForyGrpcTests/RoundTripTests.swift new file mode 100644 index 0000000000..170f43b257 --- /dev/null +++ b/integration_tests/grpc_tests/swift/interop/Tests/ForyGrpcTests/RoundTripTests.swift @@ -0,0 +1,144 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import GRPC +import NIOPosix +import XCTest + +import ForyGrpcGenerated + +private func response( + _ request: GrpcFdl.GrpcFdlRequest, _ tag: String, _ offset: Int +) -> GrpcFdl.GrpcFdlResponse { + GrpcFdl.GrpcFdlResponse( + id: "\(tag):\(request.id)", + count: request.count + Int32(offset), + payload: "\(tag):\(request.payload)") +} + +private func aggregate(_ requests: [GrpcFdl.GrpcFdlRequest]) -> GrpcFdl.GrpcFdlResponse { + GrpcFdl.GrpcFdlResponse( + id: "client:" + requests.map(\.id).joined(separator: "+"), + count: requests.reduce(0) { $0 + $1.count }, + payload: "client:" + requests.map(\.payload).joined(separator: "+")) +} + +private func stream(_ requests: [GrpcFdl.GrpcFdlRequest]) -> AsyncStream { + AsyncStream { continuation in + for request in requests { continuation.yield(request) } + continuation.finish() + } +} + +private final class FdlService: GrpcFdl_FdlGrpcServiceAsyncProvider { + func unaryMessage(request: GrpcFdl.GrpcFdlRequest, context: GRPCAsyncServerCallContext) + async throws -> GrpcFdl.GrpcFdlResponse + { + response(request, "unary", 10) + } + func serverStreamMessage( + request: GrpcFdl.GrpcFdlRequest, + responseStream: GrpcFdl_FdlGrpcServiceAsyncResponseStream, + context: GRPCAsyncServerCallContext + ) async throws { + for i in 0..<3 { try await responseStream.send(response(request, "server-\(i)", i)) } + } + func clientStreamMessage( + requestStream: GrpcFdl_FdlGrpcServiceAsyncRequestStream, + context: GRPCAsyncServerCallContext + ) async throws -> GrpcFdl.GrpcFdlResponse { + var requests: [GrpcFdl.GrpcFdlRequest] = [] + for try await request in requestStream { requests.append(request) } + return aggregate(requests) + } + func bidiStreamMessage( + requestStream: GrpcFdl_FdlGrpcServiceAsyncRequestStream, + responseStream: GrpcFdl_FdlGrpcServiceAsyncResponseStream, + context: GRPCAsyncServerCallContext + ) async throws { + var index = 0 + for try await request in requestStream { + try await responseStream.send(response(request, "bidi-\(index)", index)) + index += 1 + } + } + func unaryUnion(request: GrpcFdl.GrpcFdlUnion, context: GRPCAsyncServerCallContext) + async throws -> GrpcFdl.GrpcFdlUnion + { + request + } + func serverStreamUnion( + request: GrpcFdl.GrpcFdlUnion, + responseStream: GrpcFdl_FdlGrpcServiceAsyncResponseStream, + context: GRPCAsyncServerCallContext + ) async throws { + try await responseStream.send(request) + } + func clientStreamUnion( + requestStream: GrpcFdl_FdlGrpcServiceAsyncRequestStream, + context: GRPCAsyncServerCallContext + ) async throws -> GrpcFdl.GrpcFdlUnion { + var last = GrpcFdl.GrpcFdlUnion.response(GrpcFdl.GrpcFdlResponse()) + for try await union in requestStream { last = union } + return last + } + func bidiStreamUnion( + requestStream: GrpcFdl_FdlGrpcServiceAsyncRequestStream, + responseStream: GrpcFdl_FdlGrpcServiceAsyncResponseStream, + context: GRPCAsyncServerCallContext + ) async throws { + for try await union in requestStream { try await responseStream.send(union) } + } +} + +// Hosts the generated provider in-process and exercises the generated async client +// across all four streaming modes (the relocated SwiftPM build-and-run fixture). +final class RoundTripTests: XCTestCase { + func testInProcessAllStreamingModes() async throws { + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + defer { try? group.syncShutdownGracefully() } + let server = try await Server.insecure(group: group) + .withServiceProviders([FdlService()]) + .bind(host: "127.0.0.1", port: 0) + .get() + defer { try? server.close().wait() } + let port = server.channel.localAddress!.port! + let channel = try GRPCChannelPool.with( + target: .host("127.0.0.1", port: port), + transportSecurity: .plaintext, + eventLoopGroup: group) + defer { try? channel.close().wait() } + + let client = GrpcFdl_FdlGrpcServiceAsyncClient(channel: channel) + let first = GrpcFdl.GrpcFdlRequest(id: "a", count: 1, payload: "alpha") + let requests = [first, GrpcFdl.GrpcFdlRequest(id: "b", count: 2, payload: "beta")] + + let unary = try await client.unaryMessage(first) + XCTAssertEqual(unary, response(first, "unary", 10)) + + var served: [GrpcFdl.GrpcFdlResponse] = [] + for try await message in client.serverStreamMessage(first) { served.append(message) } + XCTAssertEqual(served, [response(first, "server-0", 0), response(first, "server-1", 1), response(first, "server-2", 2)]) + + let aggregated = try await client.clientStreamMessage(stream(requests)) + XCTAssertEqual(aggregated, aggregate(requests)) + + var bidi: [GrpcFdl.GrpcFdlResponse] = [] + for try await message in client.bidiStreamMessage(stream(requests)) { bidi.append(message) } + XCTAssertEqual(bidi, [response(requests[0], "bidi-0", 0), response(requests[1], "bidi-1", 1)]) + } +}