From 6ee6c4cfd889f067375d5459b0c23afd7e3056e8 Mon Sep 17 00:00:00 2001 From: yash Date: Fri, 19 Jun 2026 10:14:33 +0530 Subject: [PATCH 01/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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)]) + } +} From 6d44772c383917cfe3b08c53abd8291ca770749a Mon Sep 17 00:00:00 2001 From: yash Date: Mon, 22 Jun 2026 00:36:13 +0530 Subject: [PATCH 16/19] fix(compiler): make the Swift gRPC wire wrapper Sendable Mark the generated message wrapper @unchecked Sendable, a transient single-owner carrier that is serialized synchronously and never shared, so grpc-swift's async APIs accept it under Swift 6 strict concurrency, and make its value immutable. Wrap the service descriptor method list to stay within the line limit. Document that companions compile in Swift 5 language mode until generated models are Sendable. --- .../generators/services/swift.py | 21 +++++++++++++------ docs/guide/swift/grpc-support.md | 11 ++++++++++ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/compiler/fory_compiler/generators/services/swift.py b/compiler/fory_compiler/generators/services/swift.py index 978288a055..8d826dc50e 100644 --- a/compiler/fory_compiler/generators/services/swift.py +++ b/compiler/fory_compiler/generators/services/swift.py @@ -165,8 +165,11 @@ def _marshaller(self, base: str, module: str) -> List[str]: "", "// Internal Fory wire wrapper for gRPC request and response messages.", "// NIOCore.ByteBuffer is qualified because `import Fory` also exposes one.", - f"struct {base}Message: GRPCPayload {{", - " var value: Value", + "// @unchecked Sendable: a transient per-message carrier handed to a single", + "// consumer and (de)serialized synchronously, never shared across threads,", + "// so grpc-swift's async APIs can move it under Swift 6 strict concurrency.", + f"struct {base}Message: GRPCPayload, @unchecked Sendable {{", + " let value: Value", " init(_ value: Value) { self.value = value }", " init(serializedByteBuffer buffer: inout NIOCore.ByteBuffer) throws {", " let bytes = buffer.readBytes(length: buffer.readableBytes) ?? []", @@ -191,13 +194,19 @@ def _metadata(self, base: str, service: Service) -> List[str]: ) 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}])") + if service.methods: + lines.append(" methods: [") + for index, method in enumerate(service.methods): + comma = "," if index < len(service.methods) - 1 else "" + lines.append( + f" Methods.{self._swift_grpc_method_name(method)}{comma}" + ) + lines.append(" ])") + else: + lines.append(" methods: [])") lines.append("}") return lines diff --git a/docs/guide/swift/grpc-support.md b/docs/guide/swift/grpc-support.md index 7125e1dc1a..af7307809f 100644 --- a/docs/guide/swift/grpc-support.md +++ b/docs/guide/swift/grpc-support.md @@ -189,6 +189,17 @@ 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. +## Swift Language Mode + +Compile generated companions in Swift 5 language mode (use +`swift-tools-version:5.9`, or set `swiftLanguageMode(.v5)` on the target in a +6.x manifest). The Fory wire wrapper is `Sendable`, but the async client's +client-streaming and bidirectional methods pass your request and response model +types through grpc-swift's `Sendable` streaming APIs, and the generated Fory +Swift models are not yet `Sendable`. Server providers, the unary client, and the +server-streaming client build under Swift 6 strict concurrency; full Swift 6 +support for the streaming client follows once generated models are `Sendable`. + ## Known Limitations The generated client is async/await only. grpc-swift's `EventLoopFuture` client From 5c5c15c5e60e24f7cdebddeafe0756c5320da585 Mon Sep 17 00:00:00 2001 From: yash Date: Mon, 22 Jun 2026 20:09:20 +0530 Subject: [PATCH 17/19] test(grpc): run Swift toolchain gRPC tests in their package Point the shared generator at the Swift package's generated sources and run the relocated marshaller round-trip and concurrency tests with `swift test` when the toolchain is present, so they execute outside the JVM-driven suite --- integration_tests/grpc_tests/generate_grpc.py | 2 +- integration_tests/grpc_tests/run_tests.sh | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/integration_tests/grpc_tests/generate_grpc.py b/integration_tests/grpc_tests/generate_grpc.py index b0e58326e9..91684da80c 100644 --- a/integration_tests/grpc_tests/generate_grpc.py +++ b/integration_tests/grpc_tests/generate_grpc.py @@ -38,7 +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", + "swift": TEST_DIR / "swift/interop/Sources/Generated", "dart": TEST_DIR / "dart/lib/generated", } diff --git a/integration_tests/grpc_tests/run_tests.sh b/integration_tests/grpc_tests/run_tests.sh index 784f2f05ee..01f688efd9 100755 --- a/integration_tests/grpc_tests/run_tests.sh +++ b/integration_tests/grpc_tests/run_tests.sh @@ -52,6 +52,15 @@ if has_test_class "DartGrpcTest"; then dart analyze bin lib/generated/*/*_grpc.dart dart format --output=none --set-exit-if-changed bin lib/generated/*/*_grpc.dart fi +# Swift toolchain tests (generated marshaller round-trip and concurrency). These +# need the Swift toolchain rather than the JVM, so they run in their own package. +if command -v swift >/dev/null 2>&1 && [ -d "${SCRIPT_DIR}/swift/interop" ]; then + cd "${SCRIPT_DIR}/swift/interop" + swift test + if [[ "${FORY_SWIFT_TSAN:-}" == "1" ]]; then + swift test --sanitize=thread + fi +fi cd "${ROOT_DIR}/integration_tests/grpc_tests/java" mvn -T16 --no-transfer-progress \ -Dtest="${TEST_CLASSES}" \ From e21a9a43e39de266ab08c1baaa3b3f80bc732eec Mon Sep 17 00:00:00 2001 From: yash Date: Sun, 26 Jul 2026 20:35:37 +0530 Subject: [PATCH 18/19] chore(compiler): fix ruff lint in swift gRPC codegen Apply ruff 0.16 format and check fixes flagged by the Code Style Check job: combine nested validation ifs, parenthesize implicit string concatenations in generated-line lists, annotate class-level tables with ClassVar, narrow Path.resolve fallbacks to OSError/ValueError, and mark generate_grpc.py executable to match its shebang. Generated output is unchanged: compiler tests pass and regenerating the Swift gRPC interop sources produces a zero diff. --- compiler/fory_compiler/cli.py | 175 ++++++++-------- .../generators/services/swift.py | 86 ++++---- compiler/fory_compiler/generators/swift.py | 187 +++++++++--------- .../tests/test_service_codegen.py | 26 +-- integration_tests/grpc_tests/generate_grpc.py | 0 5 files changed, 240 insertions(+), 234 deletions(-) mode change 100644 => 100755 integration_tests/grpc_tests/generate_grpc.py diff --git a/compiler/fory_compiler/cli.py b/compiler/fory_compiler/cli.py index 7f6883dae9..355c1c8193 100644 --- a/compiler/fory_compiler/cli.py +++ b/compiler/fory_compiler/cli.py @@ -22,17 +22,12 @@ import os import sys from pathlib import Path -from typing import Dict, List, Optional, Set, Tuple from fory_compiler.frontend.base import FrontendError from fory_compiler.frontend.utils import parse_idl_file, resolve_import_path -from fory_compiler.ir.ast import Schema -from fory_compiler.ir.emitter import FDLEmitter -from fory_compiler.ir.validator import SchemaValidator -from fory_compiler.generators.base import GeneratorOptions from fory_compiler.generators import GENERATORS +from fory_compiler.generators.base import GeneratorOptions 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, @@ -41,13 +36,15 @@ scala_output_paths, scala_package_for_schema, ) +from fory_compiler.generators.swift import validate_swift_generation +from fory_compiler.ir.ast import Schema +from fory_compiler.ir.emitter import FDLEmitter +from fory_compiler.ir.validator import SchemaValidator class ImportError(Exception): """Error during import resolution.""" - pass - GENERATED_MARKER = "This file is generated by Apache Fory compiler." SKIP_DIR_NAMES = {"build", "target"} @@ -67,9 +64,9 @@ def is_generated_file(path: Path) -> bool: return GENERATED_MARKER in content -def scan_generated_files(root: Path, relative: bool) -> List[Path]: +def scan_generated_files(root: Path, relative: bool) -> list[Path]: """Scan for generated files under root.""" - matches: List[Path] = [] + matches: list[Path] = [] for dirpath, dirnames, filenames in os.walk(root): dirnames[:] = [d for d in dirnames if not should_skip_dir(d)] for filename in filenames: @@ -90,9 +87,9 @@ def scan_generated_files(root: Path, relative: bool) -> List[Path]: def resolve_imports( file_path: Path, - import_paths: Optional[List[Path]] = None, - visited: Optional[Set[Path]] = None, - cache: Optional[Dict[Path, Schema]] = None, + import_paths: list[Path] | None = None, + visited: set[Path] | None = None, + cache: dict[Path, Schema] | None = None, ) -> Schema: """ Recursively resolve imports and merge all types into a single schema. @@ -135,7 +132,7 @@ def resolve_imports( imported_messages = [] imported_unions = [] resolved_import_files = [] - source_packages: Dict[str, Optional[str]] = {str(file_path): schema.package} + source_packages: dict[str, str | None] = {str(file_path): schema.package} for imp in schema.imports: # Resolve import path using search paths @@ -195,7 +192,7 @@ def annotate_source_package(schema: Schema) -> None: type_def.source_package = schema.package -def go_package_info(schema: Schema) -> Tuple[Optional[str], str]: +def go_package_info(schema: Schema) -> tuple[str | None, str]: """Return (import_path, package_name) for Go.""" go_package = schema.get_option("go_package") if go_package: @@ -212,10 +209,10 @@ def go_package_info(schema: Schema) -> Tuple[Optional[str], str]: def collect_schema_graph( file_path: Path, - import_paths: List[Path], - cache: Dict[Path, Schema], - visiting: Set[Path], -) -> Optional[List[Tuple[Path, Schema]]]: + import_paths: list[Path], + cache: dict[Path, Schema], + visiting: set[Path], +) -> list[tuple[Path, Schema]] | None: """Parse a schema and its imports before generated files are written.""" file_path = file_path.resolve() if file_path in visiting: @@ -261,7 +258,7 @@ def collect_schema_graph( return entries -def validate_kotlin_import_packages(graph: List[Tuple[Path, Schema]]) -> bool: +def validate_kotlin_import_packages(graph: list[tuple[Path, Schema]]) -> bool: """Check package combinations that Kotlin source cannot compile.""" packages = {kotlin_package_for_schema(schema) for _, schema in graph} if None not in packages or len(packages) <= 1: @@ -280,11 +277,11 @@ def validate_kotlin_import_packages(graph: List[Tuple[Path, Schema]]) -> bool: def validate_kotlin_output_paths( - graph: List[Tuple[Path, Schema]], + graph: list[tuple[Path, Schema]], grpc: bool = False, ) -> bool: """Check Kotlin output paths for the current generation run.""" - outputs: Dict[str, List[str]] = {} + outputs: dict[str, list[str]] = {} for path, schema in graph: for output_path, owner in kotlin_output_paths(schema, include_services=grpc): outputs.setdefault(output_path, []).append(f"{path} {owner}") @@ -306,13 +303,13 @@ def validate_kotlin_output_paths( def validate_kotlin_generation( - files: List[Path], - import_paths: List[Path], + files: list[Path], + import_paths: list[Path], grpc: bool = False, ) -> bool: """Preflight Kotlin package and helper paths before writing output.""" - cache: Dict[Path, Schema] = {} - graph: List[Tuple[Path, Schema]] = [] + 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: @@ -324,13 +321,13 @@ def validate_kotlin_generation( def validate_csharp_files( - files: List[Path], - import_paths: List[Path], + files: list[Path], + import_paths: list[Path], grpc: bool = False, ) -> bool: """Preflight C# generated paths and module owners before writing output.""" - cache: Dict[Path, Schema] = {} - graph: List[Tuple[Path, Schema]] = [] + 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: @@ -344,14 +341,14 @@ def validate_csharp_files( def validate_swift_files( - files: List[Path], - import_paths: List[Path], - namespace_style: Optional[str] = None, + files: list[Path], + import_paths: list[Path], + namespace_style: str | None = 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]] = [] + 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: @@ -364,7 +361,7 @@ def validate_swift_files( return False -def validate_scala_import_packages(graph: List[Tuple[Path, Schema]]) -> bool: +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} if None not in packages or len(packages) <= 1: @@ -383,11 +380,11 @@ def validate_scala_import_packages(graph: List[Tuple[Path, Schema]]) -> bool: def validate_scala_output_paths( - graph: List[Tuple[Path, Schema]], + graph: list[tuple[Path, Schema]], grpc: bool = False, ) -> bool: """Check Scala output paths for the current generation run.""" - outputs: Dict[str, List[str]] = {} + outputs: dict[str, list[str]] = {} for path, schema in graph: for output_path, owner in scala_output_paths(schema, include_services=grpc): outputs.setdefault(output_path, []).append(f"{path} {owner}") @@ -409,13 +406,13 @@ def validate_scala_output_paths( def validate_scala_generation( - files: List[Path], - import_paths: List[Path], + files: list[Path], + import_paths: list[Path], grpc: bool = False, ) -> bool: """Preflight Scala package and helper paths before writing output.""" - cache: Dict[Path, Schema] = {} - graph: List[Tuple[Path, Schema]] = [] + 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: @@ -426,7 +423,7 @@ def validate_scala_generation( return validate_scala_output_paths(graph, grpc=grpc) -def _find_go_module_root(base_go_out: Path) -> Optional[Path]: +def _find_go_module_root(base_go_out: Path) -> Path | None: base_go_out = base_go_out.resolve() for candidate in (base_go_out, *base_go_out.parents): if (candidate / "go.mod").is_file(): @@ -434,7 +431,7 @@ def _find_go_module_root(base_go_out: Path) -> Optional[Path]: return None -def _read_go_module_path(go_module_root: Path) -> Optional[str]: +def _read_go_module_path(go_module_root: Path) -> str | None: module_file = go_module_root / "go.mod" if not module_file.is_file(): return None @@ -483,7 +480,7 @@ def resolve_go_output_dir(base_go_out: Path, schema: Schema) -> Path: return base_go_out -def parse_args(args: Optional[List[str]] = None) -> argparse.Namespace: +def parse_args(args: list[str] | None = None) -> argparse.Namespace: """Parse command line arguments.""" parser = argparse.ArgumentParser( prog="foryc", @@ -692,7 +689,7 @@ def parse_args(args: Optional[List[str]] = None) -> argparse.Namespace: return parser.parse_args(args) -def normalize_args(args: Optional[List[str]]) -> List[str]: +def normalize_args(args: list[str] | None) -> list[str]: """Normalize args so compile is the default command.""" if args is None: args = sys.argv[1:] @@ -709,7 +706,7 @@ def normalize_args(args: Optional[List[str]]) -> List[str]: return args -def get_languages(lang_arg: str) -> List[str]: +def get_languages(lang_arg: str) -> list[str]: """Parse the language argument into a list of languages.""" if lang_arg == "all": return list(GENERATORS.keys()) @@ -728,19 +725,19 @@ def get_languages(lang_arg: str) -> List[str]: def compile_file( file_path: Path, - lang_output_dirs: Dict[str, Path], - import_paths: Optional[List[Path]] = None, - go_nested_type_style: Optional[str] = None, - swift_namespace_style: Optional[str] = None, + lang_output_dirs: dict[str, Path], + import_paths: list[Path] | None = None, + go_nested_type_style: str | None = None, + swift_namespace_style: str | None = None, emit_fdl: bool = False, - emit_fdl_path: Optional[Path] = None, - resolve_cache: Optional[Dict[Path, Schema]] = None, + emit_fdl_path: Path | None = None, + resolve_cache: dict[Path, Schema] | None = None, grpc: bool = False, grpc_web: bool = False, grpc_python_mode: str = "async", *, - generated_outputs: Optional[Dict[Path, Path]] = None, - generated_cpp_namespaces: Optional[Dict[str, Tuple[Path, str]]] = None, + generated_outputs: dict[Path, Path] | None = None, + generated_cpp_namespaces: dict[str, tuple[Path, str]] | None = None, ) -> bool: """Compile a single IDL file with import resolution. @@ -859,7 +856,7 @@ def compile_file( "cpp": "C++", "javascript": "JavaScript", }.get(lang, lang.capitalize()) - output_targets: List[Path] = [] + output_targets: list[Path] = [] for f in files: target = (lang_output / f.path).resolve() # Reject overwriting existing non-generated files @@ -896,18 +893,18 @@ def compile_file( def compile_file_recursive( file_path: Path, - lang_output_dirs: Dict[str, Path], - import_paths: List[Path], - go_nested_type_style: Optional[str], - swift_namespace_style: Optional[str], + lang_output_dirs: dict[str, Path], + import_paths: list[Path], + go_nested_type_style: str | None, + swift_namespace_style: str | None, emit_fdl: bool, - emit_fdl_path: Optional[Path], - generated: Set[Path], - stack: Set[Path], - resolve_cache: Dict[Path, Schema], - go_module_root: Optional[Path], - generated_outputs: Dict[Path, Path], - generated_cpp_namespaces: Dict[str, Tuple[Path, str]], + emit_fdl_path: Path | None, + generated: set[Path], + stack: set[Path], + resolve_cache: dict[Path, Schema], + go_module_root: Path | None, + generated_outputs: dict[Path, Path], + generated_cpp_namespaces: dict[str, tuple[Path, str]], grpc: bool = False, grpc_web: bool = False, grpc_python_mode: str = "async", @@ -1023,7 +1020,7 @@ def cmd_compile(args: argparse.Namespace) -> int: } # Determine which languages to generate - lang_output_dirs: Dict[str, Path] = {} + lang_output_dirs: dict[str, Path] = {} # First, add languages specified via --{lang}_out (these use direct paths) for lang, out_dir in lang_specific_outputs.items(): @@ -1045,7 +1042,7 @@ def cmd_compile(args: argparse.Namespace) -> int: return 1 # Validate that all languages are supported - invalid = [lang for lang in lang_output_dirs.keys() if lang not in GENERATORS] + invalid = [lang for lang in lang_output_dirs if lang not in GENERATORS] if invalid: print(f"Error: Unknown language(s): {', '.join(invalid)}", file=sys.stderr) print(f"Available: {', '.join(GENERATORS.keys())}", file=sys.stderr) @@ -1066,20 +1063,22 @@ def cmd_compile(args: argparse.Namespace) -> int: ) import_paths.append(resolved) - if "kotlin" in lang_output_dirs: - if not validate_kotlin_generation(args.files, import_paths, grpc=args.grpc): - return 1 - if "csharp" in lang_output_dirs: - if not validate_csharp_files(args.files, import_paths, grpc=args.grpc): - return 1 - 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 "kotlin" in lang_output_dirs and not validate_kotlin_generation( + args.files, import_paths, grpc=args.grpc + ): + return 1 + if "csharp" in lang_output_dirs and not validate_csharp_files( + args.files, import_paths, grpc=args.grpc + ): + return 1 + if "scala" in lang_output_dirs and not validate_scala_generation( + args.files, import_paths, grpc=args.grpc + ): + return 1 + if "swift" in lang_output_dirs and 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( @@ -1103,10 +1102,10 @@ def cmd_compile(args: argparse.Namespace) -> int: out_dir.mkdir(parents=True, exist_ok=True) success = True - generated: Set[Path] = set() - resolve_cache: Dict[Path, Schema] = {} - generated_outputs: Dict[Path, Path] = {} - generated_cpp_namespaces: Dict[str, Tuple[Path, str]] = {} + generated: set[Path] = set() + resolve_cache: dict[Path, Schema] = {} + generated_outputs: dict[Path, Path] = {} + generated_cpp_namespaces: dict[str, tuple[Path, str]] = {} for file_path in args.files: if not file_path.exists(): print(f"Error: File not found: {file_path}", file=sys.stderr) @@ -1161,7 +1160,7 @@ def cmd_scan_generated(args: argparse.Namespace) -> int: return 0 -def main(args: Optional[List[str]] = None) -> int: +def main(args: list[str] | None = None) -> int: """Main entry point.""" parsed = parse_args(normalize_args(args)) diff --git a/compiler/fory_compiler/generators/services/swift.py b/compiler/fory_compiler/generators/services/swift.py index 8d826dc50e..fa56ea0f1e 100644 --- a/compiler/fory_compiler/generators/services/swift.py +++ b/compiler/fory_compiler/generators/services/swift.py @@ -17,8 +17,6 @@ """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 @@ -39,7 +37,7 @@ class SwiftServiceMixin: """Generates Swift gRPC service companions backed by Fory serialization.""" - def generate_services(self) -> List[GeneratedFile]: + 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 [] @@ -60,7 +58,7 @@ def swift_grpc_output_path(self, service: Service) -> str: 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]: + 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 = [ @@ -90,9 +88,9 @@ def _request_type(self, method: RpcMethod) -> str: 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: + def _check_swift_grpc_method_collisions(self, services: list[Service]) -> None: for service in services: - seen: Dict[str, str] = {} + 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: @@ -114,7 +112,7 @@ def _generate_swift_service(self, service: Service) -> GeneratedFile: methods = service.methods modes = {streaming_mode(m) for m in methods} - lines: List[str] = [] + lines: list[str] = [] lines.append(self.get_license_header("//")) lines.append("") # gRPC symbols are package-prefixed with underscores, matching grpc-swift. @@ -148,7 +146,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, base: str, 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 {", @@ -181,7 +179,7 @@ def _marshaller(self, base: str, module: str) -> List[str]: "}", ] - def _metadata(self, base: str, service: Service) -> List[str]: + 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 {") @@ -218,14 +216,14 @@ def _call_type(self, method: RpcMethod) -> str: StreamingMode.BIDIRECTIONAL: ".bidirectionalStreaming", }[streaming_mode(method)] - def _adapters(self, base: str, modes: Set[StreamingMode]) -> List[str]: + 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] = [] + lines: list[str] = [] if streamed_response: lines += self._streaming_response_context(base) lines.append("") @@ -241,7 +239,7 @@ def _adapters(self, base: str, modes: Set[StreamingMode]) -> List[str]: lines.append("") return lines - def _streaming_response_context(self, base: str) -> List[str]: + def _streaming_response_context(self, base: str) -> list[str]: return [ f"public struct {base}StreamingResponseContext {{", f" fileprivate let base: StreamingResponseCallContext<{base}Message>", @@ -253,7 +251,7 @@ def _streaming_response_context(self, base: str) -> List[str]: "}", ] - def _unary_response_context(self, base: str) -> List[str]: + def _unary_response_context(self, base: str) -> list[str]: return [ f"public struct {base}UnaryResponseContext {{", f" fileprivate let base: UnaryResponseCallContext<{base}Message>", @@ -264,7 +262,7 @@ def _unary_response_context(self, base: str) -> List[str]: "}", ] - def _async_response_stream(self, base: str) -> List[str]: + def _async_response_stream(self, base: str) -> list[str]: return [ _ASYNC_AVAILABLE, f"public struct {base}AsyncResponseStream {{", @@ -275,7 +273,7 @@ def _async_response_stream(self, base: str) -> List[str]: "}", ] - def _async_request_stream(self, base: str) -> List[str]: + def _async_request_stream(self, base: str) -> list[str]: return [ _ASYNC_AVAILABLE, f"public struct {base}AsyncRequestStream: AsyncSequence {{", @@ -293,7 +291,7 @@ def _async_request_stream(self, base: str) -> List[str]: "}", ] - def _client_response_stream(self, base: str) -> List[str]: + def _client_response_stream(self, base: str) -> list[str]: return [ _ASYNC_AVAILABLE, f"public struct {base}ResponseStream: AsyncSequence {{", @@ -311,7 +309,7 @@ def _client_response_stream(self, base: str) -> List[str]: "}", ] - def _provider(self, base: str, service: Service) -> List[str]: + 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)) @@ -333,7 +331,7 @@ def _provider(self, base: str, service: Service) -> List[str]: lines.append("}") return lines - def _handle_signature(self) -> List[str]: + def _handle_signature(self) -> list[str]: return [ " public func handle(", " method name: Substring,", @@ -341,7 +339,7 @@ def _handle_signature(self) -> List[str]: " ) -> GRPCServerHandlerProtocol? {", ] - def _provider_requirement(self, base: str, method: RpcMethod) -> List[str]: + 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) @@ -353,8 +351,10 @@ def _provider_requirement(self, base: str, method: RpcMethod) -> List[str]: ] if mode is StreamingMode.SERVER_STREAMING: return [ - f" func {name}(request: {req}, " - f"context: {base}StreamingResponseContext<{res}>)", + ( + f" func {name}(request: {req}, " + f"context: {base}StreamingResponseContext<{res}>)" + ), " -> EventLoopFuture", ] if mode is StreamingMode.CLIENT_STREAMING: @@ -367,7 +367,7 @@ def _provider_requirement(self, base: str, method: RpcMethod) -> List[str]: f" -> EventLoopFuture<(StreamEvent<{req}>) -> Void>", ] - def _provider_handler_case(self, base: str, method: RpcMethod) -> List[str]: + 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) @@ -407,15 +407,19 @@ def _provider_handler_case(self, base: str, method: RpcMethod) -> List[str]: def _client_stream_observer( self, base: str, name: str, req: str, ctx_kind: str - ) -> List[str]: + ) -> list[str]: return [ " observerFactory: { ctx in", - f" self.{name}(context: {base}{ctx_kind}(base: ctx))" - ".map { observer in", + ( + f" self.{name}(context: {base}{ctx_kind}(base: ctx))" + ".map { observer in" + ), f" {{ (event: StreamEvent<{base}Message<{req}>>) in", " switch event {", - " case .message(let wrapped): " - "observer(.message(wrapped.value))", + ( + " case .message(let wrapped): " + "observer(.message(wrapped.value))" + ), " case .end: observer(.end)", " @unknown default: break", " }", @@ -432,7 +436,7 @@ def _server_handler(self, mode: StreamingMode) -> str: StreamingMode.BIDIRECTIONAL: "BidirectionalStreamingServerHandler", }[mode] - def _async_provider(self, base: str, service: Service) -> List[str]: + def _async_provider(self, base: str, service: Service) -> list[str]: lines = [ _ASYNC_AVAILABLE, f"public protocol {base}AsyncProvider: CallHandlerProvider, Sendable {{", @@ -458,15 +462,17 @@ def _async_provider(self, base: str, service: Service) -> List[str]: lines.append("}") return lines - def _async_provider_requirement(self, base: str, method: RpcMethod) -> List[str]: + 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}", + ( + f" func {name}(request: {req}, context: GRPCAsyncServerCallContext)" + f" async throws -> {res}" + ), ] if mode is StreamingMode.SERVER_STREAMING: return [ @@ -491,7 +497,7 @@ def _async_provider_requirement(self, base: str, method: RpcMethod) -> List[str] " ) async throws", ] - def _async_provider_handler_case(self, base: str, method: RpcMethod) -> List[str]: + 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) @@ -537,7 +543,7 @@ def _async_provider_handler_case(self, base: str, method: RpcMethod) -> List[str ] return head - def _async_client(self, base: str, service: Service) -> List[str]: + def _async_client(self, base: str, service: Service) -> list[str]: lines = [ _ASYNC_AVAILABLE, f"public struct {base}AsyncClient: GRPCClient {{", @@ -554,7 +560,7 @@ def _async_client(self, base: str, service: Service) -> List[str]: lines.append("}") return lines - def _async_client_method(self, base: str, method: RpcMethod) -> List[str]: + 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) @@ -579,8 +585,10 @@ def _async_client_method(self, base: str, method: RpcMethod) -> List[str]: ] if mode is StreamingMode.CLIENT_STREAMING: return [ - f" public func {name}(_ requests: S)" - f" async throws -> {res}", + ( + f" public func {name}(_ requests: S)" + f" async throws -> {res}" + ), f" where S.Element == {req} {{", f" let response: {base}Message<{res}> = try await performAsyncClientStreamingCall(", f" path: {path},", @@ -589,8 +597,10 @@ def _async_client_method(self, base: str, method: RpcMethod) -> List[str]: " }", ] return [ - f" public func {name}(_ requests: S)" - f" -> {base}ResponseStream<{res}>", + ( + f" public func {name}(_ requests: S)" + f" -> {base}ResponseStream<{res}>" + ), f" where S.Element == {req} {{", f" {base}ResponseStream(base: performAsyncBidirectionalStreamingCall(", f" path: {path},", diff --git a/compiler/fory_compiler/generators/swift.py b/compiler/fory_compiler/generators/swift.py index b3b88faa98..b11f0f2254 100644 --- a/compiler/fory_compiler/generators/swift.py +++ b/compiler/fory_compiler/generators/swift.py @@ -18,7 +18,7 @@ """Swift code generator.""" from pathlib import Path -from typing import Dict, List, Optional, Set, Tuple, Union as TypingUnion +from typing import ClassVar from fory_compiler.frontend.utils import parse_idl_file from fory_compiler.generators.base import BaseGenerator, GeneratedFile, GeneratorOptions @@ -45,7 +45,7 @@ class SwiftGenerator(SwiftServiceMixin, BaseGenerator): language_name = "swift" file_extension = ".swift" - PRIMITIVE_MAP = { + PRIMITIVE_MAP: ClassVar[dict] = { PrimitiveKind.BOOL: "Bool", PrimitiveKind.INT8: "Int8", PrimitiveKind.INT16: "Int16", @@ -68,7 +68,7 @@ class SwiftGenerator(SwiftServiceMixin, BaseGenerator): PrimitiveKind.ANY: "Any", } - SWIFT_KEYWORDS = { + SWIFT_KEYWORDS: ClassVar[set] = { "associatedtype", "class", "deinit", @@ -131,18 +131,18 @@ class SwiftGenerator(SwiftServiceMixin, BaseGenerator): def __init__(self, schema: Schema, options): super().__init__(schema, options) - self._qualified_type_names: Dict[int, str] = {} - self._schema_cache: Dict[Path, Schema] = {} - self._equatable_cache: Dict[int, bool] = {} - self._messages_requiring_class: Set[int] = set() + self._qualified_type_names: dict[int, str] = {} + self._schema_cache: dict[Path, Schema] = {} + self._equatable_cache: dict[int, bool] = {} + self._messages_requiring_class: set[int] = set() self._build_qualified_type_name_index() self._collect_messages_requiring_class() - def generate(self) -> List[GeneratedFile]: + def generate(self) -> list[GeneratedFile]: return [self.generate_file()] def generate_file(self) -> GeneratedFile: - lines: List[str] = [] + lines: list[str] = [] lines.append(self.get_license_header("//")) lines.append("") lines.append("import Foundation") @@ -193,14 +193,14 @@ def output_file_path(self) -> str: return f"{package_path}/{file_name}" return file_name - def swift_model_top_level_symbols(self) -> Set[str]: + 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() + 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 @@ -220,7 +220,7 @@ def module_file_name(self) -> str: return f"{self.schema.package.replace('.', '_')}.swift" return "generated.swift" - def get_namespace_components(self) -> List[str]: + def get_namespace_components(self) -> list[str]: return self._namespace_components_for_schema(self.schema) def namespace_path(self) -> str: @@ -290,7 +290,7 @@ def is_imported_type(self, type_def: object) -> bool: return ( Path(location.file).resolve() != Path(self.schema.source_file).resolve() ) - except Exception: + except (OSError, ValueError): return location.file != self.schema.source_file def _normalize_import_path(self, path_str: str) -> str: @@ -298,23 +298,24 @@ def _normalize_import_path(self, path_str: str) -> str: return path_str try: return str(Path(path_str).resolve()) - except Exception: + except (OSError, ValueError): return path_str - def _load_schema(self, file_path: str) -> Optional[Schema]: + def _load_schema(self, file_path: str) -> Schema | None: if not file_path: return None path = Path(file_path).resolve() if path in self._schema_cache: return self._schema_cache[path] + # Any parse failure means the import is skipped, so keep the catch broad. try: schema = parse_idl_file(path) - except Exception: + except Exception: # noqa: BLE001 return None self._schema_cache[path] = schema return schema - def _package_components_for_schema(self, schema: Schema) -> List[str]: + def _package_components_for_schema(self, schema: Schema) -> list[str]: package = schema.package if not package: return [] @@ -324,7 +325,7 @@ def _package_components_for_schema(self, schema: Schema) -> List[str]: if part ] - def _namespace_components_for_schema(self, schema: Schema) -> List[str]: + def _namespace_components_for_schema(self, schema: Schema) -> list[str]: if self.get_namespace_style() != "enum": return [] components = self._package_components_for_schema(schema) @@ -356,13 +357,13 @@ def _flattened_type_path(self, schema: Schema, qualified_name: str) -> str: ] return ".".join([top_level, *nested]) - def _local_top_level_type_names(self, schema: Schema) -> Set[str]: - names: Set[str] = set() - source_path: Optional[Path] = None + def _local_top_level_type_names(self, schema: Schema) -> set[str]: + names: set[str] = set() + source_path: Path | None = None if schema.source_file: try: source_path = Path(schema.source_file).resolve() - except Exception: + except (OSError, ValueError): source_path = None for type_def in schema.enums + schema.unions + schema.messages: @@ -373,7 +374,7 @@ def _local_top_level_type_names(self, schema: Schema) -> Set[str]: try: if Path(type_file).resolve() != source_path: continue - except Exception: + except (OSError, ValueError): if type_file != schema.source_file: continue names.add(self.safe_type_identifier(self.to_pascal_case(type_def.name))) @@ -387,8 +388,8 @@ def _namespace_path_for_type(self, type_def: object) -> str: return self.namespace_path() return ".".join(self._namespace_components_for_schema(schema)) - def _collect_imported_module_paths(self) -> List[str]: - by_file: Dict[str, str] = {} + def _collect_imported_module_paths(self) -> list[str]: + by_file: dict[str, str] = {} for type_def in self.schema.enums + self.schema.unions + self.schema.messages: if not self.is_imported_type(type_def): continue @@ -404,8 +405,8 @@ def _collect_imported_module_paths(self) -> List[str]: continue by_file[normalized] = self._module_type_path_for_schema(schema) - ordered: List[str] = [] - used_paths: Set[str] = set() + ordered: list[str] = [] + used_paths: set[str] = set() if self.schema.source_file: base_dir = Path(self.schema.source_file).resolve().parent for imp in self.schema.imports: @@ -420,8 +421,8 @@ def _collect_imported_module_paths(self) -> List[str]: continue ordered.append(by_file[key]) - deduped: List[str] = [] - seen: Set[str] = set() + deduped: list[str] = [] + seen: set[str] = set() for path in ordered: if path == self.module_type_path(): continue @@ -437,7 +438,7 @@ def _build_qualified_type_name_index(self) -> None: for union in self.schema.unions: self._qualified_type_names[id(union)] = union.name - def visit_message(message: Message, parents: List[str]) -> None: + def visit_message(message: Message, parents: list[str]) -> None: path = ".".join(parents + [message.name]) self._qualified_type_names[id(message)] = path for nested_enum in message.nested_enums: @@ -463,7 +464,7 @@ def _collect_messages_requiring_class_for_message(self, message: Message) -> Non self._messages_requiring_class.add(id(message)) for field in message.fields: - ref_target_type: Optional[FieldType] = None + ref_target_type: FieldType | None = None has_ref_semantics = False if field.ref: ref_target_type = field.field_type @@ -506,8 +507,8 @@ def message_is_class(self, message: Message) -> bool: def _resolve_named_type( self, name: str, - parent_stack: Optional[List[Message]] = None, - ) -> Optional[TypingUnion[Message, Enum, Union]]: + parent_stack: list[Message] | None = None, + ) -> Message | Enum | Union | None: parent_stack = parent_stack or [] if "." in name: return self.schema.get_type(name) @@ -528,7 +529,7 @@ def _schema_for_type(self, type_def: object) -> Schema: def _declared_type_name( self, name: str, - parent_stack: Optional[List[Message]] = None, + parent_stack: list[Message] | None = None, ) -> str: type_name = self.safe_type_identifier(self.to_pascal_case(name)) if parent_stack: @@ -540,7 +541,7 @@ def _declared_type_name( def _qualified_type_path( self, - resolved: Optional[TypingUnion[Message, Enum, Union]], + resolved: Message | Enum | Union | None, fallback_name: str, ) -> str: owner_schema = self.schema @@ -562,7 +563,7 @@ def _qualified_type_path( def _named_type_reference( self, named_type: NamedType, - parent_stack: Optional[List[Message]] = None, + parent_stack: list[Message] | None = None, nullable: bool = False, ) -> str: resolved = self._resolve_named_type(named_type.name, parent_stack) @@ -575,7 +576,7 @@ def generate_type( # type: ignore[override] self, field_type: FieldType, nullable: bool = False, - parent_stack: Optional[List[Message]] = None, + parent_stack: list[Message] | None = None, ) -> str: parent_stack = parent_stack or [] if isinstance(field_type, PrimitiveType): @@ -629,7 +630,7 @@ def generate_type( # type: ignore[override] return "Any" - def field_encoding_argument(self, field: Field) -> Optional[str]: + def field_encoding_argument(self, field: Field) -> str | None: field_type = field.field_type if not isinstance(field_type, PrimitiveType): return None @@ -645,7 +646,7 @@ def type_hint_expression( nullable: bool = False, *, array_element: bool = False, - ) -> Optional[str]: + ) -> str | None: if isinstance(field_type, PrimitiveType): return self.scalar_type_hint_expression( field_type, @@ -675,7 +676,7 @@ def type_hint_expression( value_hint = self.type_hint_expression( field_type.value_type, nullable=field_type.value_optional ) - parts: List[str] = [] + parts: list[str] = [] if key_hint is not None: parts.append(f"key: {key_hint}") if value_hint is not None: @@ -692,7 +693,7 @@ def scalar_type_hint_expression( nullable: bool = False, *, include_integer_encoding: bool = True, - ) -> Optional[str]: + ) -> str | None: kind = field_type.kind member_hints = { PrimitiveKind.BOOL: "bool", @@ -727,7 +728,7 @@ def scalar_type_hint_expression( name = callable_hint encoding = field_type.encoding_modifier - args: List[str] = [] + args: list[str] = [] if nullable: args.append("nullable: true") if include_integer_encoding and encoding is not None: @@ -736,7 +737,7 @@ def scalar_type_hint_expression( return f".{name}()" return f".{name}({', '.join(args)})" - def nested_field_attribute(self, field: Field) -> Optional[str]: + def nested_field_attribute(self, field: Field) -> str | None: field_type = field.field_type if isinstance(field_type, ListType): element_hint = self.type_hint_expression( @@ -760,7 +761,7 @@ def nested_field_attribute(self, field: Field) -> Optional[str]: value_hint = self.type_hint_expression( field_type.value_type, nullable=field_type.value_optional ) - parts: List[str] = [] + parts: list[str] = [] if key_hint is not None: parts.append(f"key: {key_hint}") if value_hint is not None: @@ -771,7 +772,7 @@ def nested_field_attribute(self, field: Field) -> Optional[str]: return None - def message_field_id_argument(self, field: Field) -> Optional[int]: + def message_field_id_argument(self, field: Field) -> int | None: return field.tag_id def union_case_id_argument(self, field: Field) -> int: @@ -787,7 +788,7 @@ def is_weak_ref_field(self, field: Field) -> bool: def field_swift_type( self, field: Field, - parent_stack: List[Message], + parent_stack: list[Message], ) -> str: weak_ref = self.is_weak_ref_field(field) nullable = field.optional or weak_ref @@ -801,7 +802,7 @@ def field_default_expression( self, field: Field, type_name: str, - ) -> Optional[str]: + ) -> str | None: if self.is_weak_ref_field(field): return None if field.optional: @@ -847,8 +848,8 @@ def field_default_expression( def type_supports_equatable( self, field_type: FieldType, - parent_stack: Optional[List[Message]] = None, - visiting: Optional[Set[int]] = None, + parent_stack: list[Message] | None = None, + visiting: set[int] | None = None, ) -> bool: parent_stack = parent_stack or [] if isinstance(field_type, PrimitiveType): @@ -881,15 +882,13 @@ def type_supports_equatable( return self.message_supports_equatable(resolved, visiting=visiting) if isinstance(resolved, Union): return self.union_supports_equatable(resolved, visiting=visiting) - if isinstance(resolved, Enum): - return True - return False + return isinstance(resolved, Enum) def union_supports_equatable( self, union: Union, - parent_stack: Optional[List[Message]] = None, - visiting: Optional[Set[int]] = None, + parent_stack: list[Message] | None = None, + visiting: set[int] | None = None, ) -> bool: if visiting is None: visiting = set() @@ -904,7 +903,7 @@ def union_supports_equatable( def message_supports_equatable( self, message: Message, - visiting: Optional[Set[int]] = None, + visiting: set[int] | None = None, ) -> bool: if self.message_is_class(message): return False @@ -931,10 +930,10 @@ def message_supports_equatable( visiting.remove(key) return True - def _lineage_for_message(self, message: Message) -> List[Message]: - lineage: List[Message] = [] + def _lineage_for_message(self, message: Message) -> list[Message]: + lineage: list[Message] = [] - def visit(current: Message, parents: List[Message]) -> bool: + def visit(current: Message, parents: list[Message]) -> bool: if current is message: lineage.extend(parents + [current]) return True @@ -952,9 +951,9 @@ def generate_enum( self, enum: Enum, indent: int = 0, - parent_stack: Optional[List[Message]] = None, - ) -> List[str]: - lines: List[str] = [] + parent_stack: list[Message] | None = None, + ) -> list[str]: + lines: list[str] = [] ind = self.indent_str * indent type_name = self._declared_type_name(enum.name, parent_stack) comment = self.format_type_id_comment(enum, f"{ind}//") @@ -975,9 +974,9 @@ def generate_union( self, union: Union, indent: int = 0, - parent_stack: Optional[List[Message]] = None, - ) -> List[str]: - lines: List[str] = [] + parent_stack: list[Message] | None = None, + ) -> list[str]: + lines: list[str] = [] ind = self.indent_str * indent type_name = self._declared_type_name(union.name, parent_stack) comment = self.format_type_id_comment(union, f"{ind}//") @@ -1017,9 +1016,9 @@ def generate_message( self, message: Message, indent: int = 0, - parent_stack: Optional[List[Message]] = None, - ) -> List[str]: - lines: List[str] = [] + parent_stack: list[Message] | None = None, + ) -> list[str]: + lines: list[str] = [] ind = self.indent_str * indent parent_stack = parent_stack or [] lineage = parent_stack + [message] @@ -1092,12 +1091,12 @@ def generate_message( def generate_message_fields( self, message: Message, - lineage: List[Message], + lineage: list[Message], indent: int, - ) -> List[str]: - lines: List[str] = [] + ) -> list[str]: + lines: list[str] = [] ind = self.indent_str * indent - used_names: Set[str] = set() + used_names: set[str] = set() for field in message.fields: field_name = self.safe_member_name(field.name) @@ -1110,7 +1109,7 @@ def generate_message_fields( encoding = self.field_encoding_argument(field) field_id = self.message_field_id_argument(field) - attr_parts: List[str] = [] + attr_parts: list[str] = [] if field_id is not None: attr_parts.append(f"id: {field_id}") if encoding is not None: @@ -1136,18 +1135,18 @@ def generate_message_fields( def generate_struct_init( self, message: Message, - lineage: List[Message], + lineage: list[Message], indent: int, - ) -> List[str]: + ) -> list[str]: ind = self.indent_str * indent - lines: List[str] = [] + lines: list[str] = [] if not message.fields: lines.append(f"{ind}public init() {{}}") return lines - params: List[str] = [] - assignments: List[str] = [] - used_names: Set[str] = set() + params: list[str] = [] + assignments: list[str] = [] + used_names: set[str] = set() for field in message.fields: field_name = self.safe_member_name(field.name) suffix = 1 @@ -1174,9 +1173,9 @@ def generate_struct_init( lines.append(f"{ind}}}") return lines - def generate_bytes_methods(self, type_name: str, indent: int) -> List[str]: + def generate_bytes_methods(self, type_name: str, indent: int) -> list[str]: ind = self.indent_str * indent - lines: List[str] = [] + lines: list[str] = [] module_path = self.module_type_path() lines.append(f"{ind}public func toBytes() throws -> Data {{") lines.append( @@ -1193,8 +1192,8 @@ def generate_bytes_methods(self, type_name: str, indent: int) -> List[str]: lines.append(f"{ind}}}") return lines - def collect_local_types(self) -> List[TypingUnion[Message, Enum, Union]]: - local: List[TypingUnion[Message, Enum, Union]] = [] + def collect_local_types(self) -> list[Message | Enum | Union]: + local: list[Message | Enum | Union] = [] for enum in self.schema.enums: if not self.is_imported_type(enum): local.append(enum) @@ -1203,10 +1202,8 @@ def collect_local_types(self) -> List[TypingUnion[Message, Enum, Union]]: local.append(union) def visit_message(message: Message) -> None: - for nested_enum in message.nested_enums: - local.append(nested_enum) - for nested_union in message.nested_unions: - local.append(nested_union) + local.extend(message.nested_enums) + local.extend(message.nested_unions) for nested_msg in message.nested_messages: visit_message(nested_msg) local.append(message) @@ -1220,18 +1217,18 @@ def visit_message(message: Message) -> None: def schema_type_name( self, - type_def: TypingUnion[Message, Enum, Union], + type_def: Message | Enum | Union, ) -> str: return self._qualified_type_names.get(id(type_def), type_def.name) def schema_swift_type( self, - type_def: TypingUnion[Message, Enum, Union], + type_def: Message | Enum | Union, ) -> str: return self._qualified_type_path(type_def, self.schema_type_name(type_def)) - def generate_module_type(self, indent: int = 0) -> List[str]: - lines: List[str] = [] + def generate_module_type(self, indent: int = 0) -> list[str]: + lines: list[str] = [] ind = self.indent_str * indent module_type_name = self._module_helper_name_for_schema(self.schema) lines.append(f"{ind}public enum {module_type_name} {{") @@ -1291,13 +1288,13 @@ def generate_module_type(self, indent: int = 0) -> List[str]: def validate_swift_generation( - graph: List[Tuple[Path, Schema]], - namespace_style: Optional[str] = None, + graph: list[tuple[Path, Schema]], + namespace_style: str | None = 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]] = {} + 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 @@ -1333,7 +1330,7 @@ def validate_swift_generation( return True -def _raise_swift_collision(owners: Dict[str, List[str]], message: str) -> None: +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 diff --git a/compiler/fory_compiler/tests/test_service_codegen.py b/compiler/fory_compiler/tests/test_service_codegen.py index 3f85fde3d1..4be96ae6fd 100644 --- a/compiler/fory_compiler/tests/test_service_codegen.py +++ b/compiler/fory_compiler/tests/test_service_codegen.py @@ -17,28 +17,29 @@ """Codegen smoke tests for schemas that contain service definitions.""" -from pathlib import Path import shutil import subprocess +from pathlib import Path from textwrap import dedent -from typing import Dict, Tuple, Type import pytest from fory_compiler.cli import ( cmd_compile, compile_file, - main as foryc_main, 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 +from fory_compiler.cli import ( + main as foryc_main, +) from fory_compiler.frontend.fbs.lexer import Lexer as FbsLexer from fory_compiler.frontend.fbs.parser import Parser as FbsParser from fory_compiler.frontend.fbs.translator import FbsTranslator +from fory_compiler.frontend.fdl.lexer import Lexer +from fory_compiler.frontend.fdl.parser import Parser from fory_compiler.frontend.proto.lexer import Lexer as ProtoLexer from fory_compiler.frontend.proto.parser import Parser as ProtoParser from fory_compiler.frontend.proto.translator import ProtoTranslator @@ -57,8 +58,7 @@ from fory_compiler.ir.ast import Schema from fory_compiler.ir.validator import SchemaValidator - -GENERATOR_CLASSES: Tuple[Type[BaseGenerator], ...] = ( +GENERATOR_CLASSES: tuple[type[BaseGenerator], ...] = ( JavaGenerator, PythonGenerator, CppGenerator, @@ -120,16 +120,16 @@ def parse_fbs(source: str) -> Schema: def generate_files( - schema: Schema, generator_cls: Type[BaseGenerator] -) -> Dict[str, str]: + schema: Schema, generator_cls: type[BaseGenerator] +) -> dict[str, str]: options = GeneratorOptions(output_dir=Path("/tmp")) generator = generator_cls(schema, options) return {item.path: item.content for item in generator.generate()} def generate_service_files( - schema: Schema, generator_cls: Type[BaseGenerator] -) -> Dict[str, str]: + schema: Schema, generator_cls: type[BaseGenerator] +) -> dict[str, str]: options = GeneratorOptions(output_dir=Path("/tmp"), grpc=True) generator = generator_cls(schema, options) return {item.path: item.content for item in generator.generate_services()} @@ -3810,10 +3810,10 @@ def test_dart_grpc_method_aliases(tmp_path: Path): def test_dart_grpc_reserved_methods(): - from fory_compiler.generators.dart import DartGenerator - import pytest + from fory_compiler.generators.dart import DartGenerator + for rpc_name, emitted in [("ToString", "toString"), ("HashCode", "hashCode")]: schema = parse_fdl( dedent( diff --git a/integration_tests/grpc_tests/generate_grpc.py b/integration_tests/grpc_tests/generate_grpc.py old mode 100644 new mode 100755 From de06611c6e69bd0c97a7d37d15f4ed18675259f8 Mon Sep 17 00:00:00 2001 From: yash Date: Sun, 26 Jul 2026 19:45:34 +0530 Subject: [PATCH 19/19] fix(java): balance read ref id stack for NOT_NULL values A peer with ref tracking disabled globally or per type sends NOT_NULL_VALUE_FLAG where a tracking reader preserves no read ref id, so reads that bind instances through ReadContext#reference popped an outer frame's id or underflowed the ref id stack (Index -1). Reserve a no-op -1 placeholder once the value's serializer is resolved and participates in ref tracking; setReadRef ignores negative ids. Applied per call site instead of centrally in tryPreserveRefId because string, primitive, and compatible-array reads never pop. --- .../src/main/java/org/apache/fory/Fory.java | 1 + .../fory/builder/BaseObjectCodecBuilder.java | 7 +- .../org/apache/fory/context/ReadContext.java | 32 +++ .../serializer/AbstractObjectSerializer.java | 16 +- .../fory/serializer/ArraySerializers.java | 17 +- .../serializer/ReplaceResolveSerializer.java | 8 +- .../apache/fory/serializer/Serializer.java | 3 + .../fory/serializer/UnionSerializer.java | 3 + .../serializer/UnknownCaseSerializer.java | 1 + .../NotNullValueRefTrackingTest.java | 205 ++++++++++++++++++ 10 files changed, 282 insertions(+), 11 deletions(-) create mode 100644 java/fory-core/src/test/java/org/apache/fory/serializer/NotNullValueRefTrackingTest.java diff --git a/java/fory-core/src/main/java/org/apache/fory/Fory.java b/java/fory-core/src/main/java/org/apache/fory/Fory.java index a7916400ae..4ec96eb9df 100644 --- a/java/fory-core/src/main/java/org/apache/fory/Fory.java +++ b/java/fory-core/src/main/java/org/apache/fory/Fory.java @@ -561,6 +561,7 @@ private T deserializeByType(MemoryBuffer buffer, Class type) { return (T) refReader.getReadRef(); } TypeInfo typeInfo = typeResolver.readTypeInfo(readContext, type); + readContext.preserveNotNullValueRefId(nextReadRefId, typeInfo.getSerializer()); Object value = readContext.readNonRef(typeInfo); refReader.setReadRef(nextReadRefId, value); return (T) value; diff --git a/java/fory-core/src/main/java/org/apache/fory/builder/BaseObjectCodecBuilder.java b/java/fory-core/src/main/java/org/apache/fory/builder/BaseObjectCodecBuilder.java index 7bdda7394c..23b613dafc 100644 --- a/java/fory-core/src/main/java/org/apache/fory/builder/BaseObjectCodecBuilder.java +++ b/java/fory-core/src/main/java/org/apache/fory/builder/BaseObjectCodecBuilder.java @@ -2178,13 +2178,18 @@ private Expression readRef( // indicates that the object is first read. Expression needDeserialize = ExpressionUtils.egt(refId, new Literal(Fory.NOT_NULL_VALUE_FLAG, PRIMITIVE_BYTE_TYPE)); + // a peer without ref tracking for this type sends NOT_NULL with no id preserved; reserve a + // placeholder so the nested read's `readContext.reference` stays balanced + Expression preserveNotNullRefId = invokeReadContext("preserveNotNullValueRefId", refId); Expression deserializedValue = deserializeForNotNull.get(); Expression setReadRef = invokeReadContext("setReadRef", refId, deserializedValue); Expression readValue = inlineInvokeReadContext("getReadRef", OBJECT_TYPE); // use false to ignore null return new If( needDeserialize, - callback.apply(new ListExpression(refId, deserializedValue, setReadRef, deserializedValue)), + callback.apply( + new ListExpression( + refId, preserveNotNullRefId, deserializedValue, setReadRef, deserializedValue)), callback.apply(readValue), false); } diff --git a/java/fory-core/src/main/java/org/apache/fory/context/ReadContext.java b/java/fory-core/src/main/java/org/apache/fory/context/ReadContext.java index 75e23df68e..d428c1d4bc 100644 --- a/java/fory-core/src/main/java/org/apache/fory/context/ReadContext.java +++ b/java/fory-core/src/main/java/org/apache/fory/context/ReadContext.java @@ -384,6 +384,34 @@ public int tryPreserveRefId() { return refReader.tryPreserveRefId(buffer); } + /** + * Reserves a no-op ref id when a {@link Fory#NOT_NULL_VALUE_FLAG} header precedes a serializer + * whose read binds its instance through {@link #reference(Object)}. + * + *

A peer may disable reference tracking globally or per type and send {@code NOT_NULL} where + * this runtime preserved no id, so tracked reads must reserve a placeholder to keep the + * serializer's {@code reference} pop balanced. {@link #setReadRef(int, Object)} ignores the + * negative id. Must be called only after the value's serializer is resolved; a {@code REF_VALUE} + * header already preserved the real id and pushing again would shadow it, breaking circular + * references. + */ + public void preserveNotNullValueRefId(int nextReadRefId, Serializer serializer) { + if (nextReadRefId == Fory.NOT_NULL_VALUE_FLAG && serializer.needToWriteRef()) { + refReader.preserveRefId(-1); + } + } + + /** + * Variant of {@link #preserveNotNullValueRefId(int, Serializer)} for generated codecs, which only + * emit ref headers for declared types that participate in ref tracking. Kept to a single int + * argument so the generated call stays small enough for method-size budgets. + */ + public void preserveNotNullValueRefId(int nextReadRefId) { + if (nextReadRefId == Fory.NOT_NULL_VALUE_FLAG) { + refReader.preserveRefId(-1); + } + } + /** Returns the last ref id preserved by the active {@link RefReader}. */ public int lastPreservedRefId() { return refReader.lastPreservedRefId(); @@ -569,6 +597,7 @@ public Object readRef() { int nextReadRefId = refReader.tryPreserveRefId(buffer); if (nextReadRefId >= Fory.NOT_NULL_VALUE_FLAG) { TypeInfo typeInfo = typeResolver.readTypeInfo(this); + preserveNotNullValueRefId(nextReadRefId, typeInfo.getSerializer()); Object o = readNonRef(typeInfo); refReader.setReadRef(nextReadRefId, o); return o; @@ -580,6 +609,7 @@ public Object readRef() { public Object readRef(TypeInfo typeInfo) { int nextReadRefId = refReader.tryPreserveRefId(buffer); if (nextReadRefId >= Fory.NOT_NULL_VALUE_FLAG) { + preserveNotNullValueRefId(nextReadRefId, typeInfo.getSerializer()); Object o = readNonRef(typeInfo); refReader.setReadRef(nextReadRefId, o); return o; @@ -592,6 +622,7 @@ public Object readRef(TypeInfoHolder classInfoHolder) { int nextReadRefId = refReader.tryPreserveRefId(buffer); if (nextReadRefId >= Fory.NOT_NULL_VALUE_FLAG) { TypeInfo typeInfo = typeResolver.readTypeInfo(this, classInfoHolder); + preserveNotNullValueRefId(nextReadRefId, typeInfo.getSerializer()); Object o = readNonRef(typeInfo); refReader.setReadRef(nextReadRefId, o); return o; @@ -604,6 +635,7 @@ public T readRef(Serializer serializer) { if (serializer.needToWriteRef()) { int nextReadRefId = refReader.tryPreserveRefId(buffer); if (nextReadRefId >= Fory.NOT_NULL_VALUE_FLAG) { + preserveNotNullValueRefId(nextReadRefId, serializer); Object o = readNonRef(serializer); refReader.setReadRef(nextReadRefId, o); return (T) o; diff --git a/java/fory-core/src/main/java/org/apache/fory/serializer/AbstractObjectSerializer.java b/java/fory-core/src/main/java/org/apache/fory/serializer/AbstractObjectSerializer.java index 0681127faf..b0ca7899c4 100644 --- a/java/fory-core/src/main/java/org/apache/fory/serializer/AbstractObjectSerializer.java +++ b/java/fory-core/src/main/java/org/apache/fory/serializer/AbstractObjectSerializer.java @@ -190,11 +190,10 @@ static Object readField( if (refMode == RefMode.TRACKING) { int nextReadRefId = readContext.tryPreserveRefId(); if (nextReadRefId >= Fory.NOT_NULL_VALUE_FLAG) { - Object value = - typeResolver - .readTypeInfo(readContext, fieldInfo.type) - .getSerializer() - .read(readContext); + Serializer serializer = + typeResolver.readTypeInfo(readContext, fieldInfo.type).getSerializer(); + readContext.preserveNotNullValueRefId(nextReadRefId, serializer); + Object value = serializer.read(readContext); refReader.setReadRef(nextReadRefId, value); return value; } @@ -554,11 +553,16 @@ private static Object readContainerFieldValueRef( if (nextReadRefId >= Fory.NOT_NULL_VALUE_FLAG) { Object value; if (fieldInfo.containerSerializerOverride != null) { + readContext.preserveNotNullValueRefId(nextReadRefId, fieldInfo.containerSerializerOverride); value = readContext.readNonRef(fieldInfo.containerSerializerOverride); } else if (readContext.getConfig().isXlang()) { + readContext.preserveNotNullValueRefId( + nextReadRefId, fieldInfo.containerTypeInfo.getSerializer()); value = readContext.readNonRef(fieldInfo.containerTypeInfo); } else { - value = readContext.readData(typeResolver.readTypeInfo(readContext)); + TypeInfo typeInfo = typeResolver.readTypeInfo(readContext); + readContext.preserveNotNullValueRefId(nextReadRefId, typeInfo.getSerializer()); + value = readContext.readData(typeInfo); } refReader.setReadRef(nextReadRefId, value); return value; diff --git a/java/fory-core/src/main/java/org/apache/fory/serializer/ArraySerializers.java b/java/fory-core/src/main/java/org/apache/fory/serializer/ArraySerializers.java index 039ac96c5c..08f178ef69 100644 --- a/java/fory-core/src/main/java/org/apache/fory/serializer/ArraySerializers.java +++ b/java/fory-core/src/main/java/org/apache/fory/serializer/ArraySerializers.java @@ -579,8 +579,11 @@ private static Object readRefElement( Class declaredElementType) { int nextReadRefId = readContext.tryPreserveRefId(); if (nextReadRefId >= Fory.NOT_NULL_VALUE_FLAG) { - Object element = - readNonRefElement(readContext, typeResolver, elementTypeInfoHolder, declaredElementType); + TypeInfo elementTypeInfo = typeResolver.readTypeInfo(readContext, elementTypeInfoHolder); + Serializer serializer = + resolveElementSerializer(typeResolver, declaredElementType, elementTypeInfo); + readContext.preserveNotNullValueRefId(nextReadRefId, serializer); + Object element = readElement(readContext, elementTypeInfo, serializer); readContext.setReadRef(nextReadRefId, element); return element; } @@ -595,6 +598,16 @@ private static Object readNonRefElement( TypeInfo elementTypeInfo = typeResolver.readTypeInfo(readContext, elementTypeInfoHolder); Serializer serializer = resolveElementSerializer(typeResolver, declaredElementType, elementTypeInfo); + if (serializer.needToWriteRef()) { + // elements written without ref flags may still bind through `readContext.reference`; + // reserve a placeholder so the pop stays balanced + readContext.preserveRefId(-1); + } + return readElement(readContext, elementTypeInfo, serializer); + } + + private static Object readElement( + ReadContext readContext, TypeInfo elementTypeInfo, Serializer serializer) { if (serializer == elementTypeInfo.getSerializer()) { return readContext.readNonRef(elementTypeInfo); } diff --git a/java/fory-core/src/main/java/org/apache/fory/serializer/ReplaceResolveSerializer.java b/java/fory-core/src/main/java/org/apache/fory/serializer/ReplaceResolveSerializer.java index c07ae31326..b95331dee1 100644 --- a/java/fory-core/src/main/java/org/apache/fory/serializer/ReplaceResolveSerializer.java +++ b/java/fory-core/src/main/java/org/apache/fory/serializer/ReplaceResolveSerializer.java @@ -398,7 +398,9 @@ public Object read(ReadContext readContext) { int nextReadRefId = readContext.tryPreserveRefId(); if (nextReadRefId >= Fory.NOT_NULL_VALUE_FLAG) { // ref value or not-null value - Object o = readContext.readData(classResolver.readTypeInfo(readContext)); + TypeInfo typeInfo = classResolver.readTypeInfo(readContext); + readContext.preserveNotNullValueRefId(nextReadRefId, typeInfo.getSerializer()); + Object o = readContext.readData(typeInfo); readContext.setReadRef(nextReadRefId, o); readContext.setReadRef(outerRefId, o); return o; @@ -409,7 +411,9 @@ public Object read(ReadContext readContext) { int outerRefId = readContext.lastPreservedRefId(); int nextReadRefId = readContext.tryPreserveRefId(); if (nextReadRefId >= Fory.NOT_NULL_VALUE_FLAG) { - // ref value or not-null value + // ref value or not-null value; the nested JDK object serializer binds through + // `readContext.reference`, so mirror this serializer's ref participation + readContext.preserveNotNullValueRefId(nextReadRefId, this); Object o = readObject(readContext); readContext.setReadRef(nextReadRefId, o); readContext.setReadRef(outerRefId, o); diff --git a/java/fory-core/src/main/java/org/apache/fory/serializer/Serializer.java b/java/fory-core/src/main/java/org/apache/fory/serializer/Serializer.java index 9e76be18e4..569a38d994 100644 --- a/java/fory-core/src/main/java/org/apache/fory/serializer/Serializer.java +++ b/java/fory-core/src/main/java/org/apache/fory/serializer/Serializer.java @@ -153,6 +153,9 @@ public T read(ReadContext readContext, RefMode refMode) { T obj; int nextReadRefId = readContext.tryPreserveRefId(); if (nextReadRefId >= Fory.NOT_NULL_VALUE_FLAG) { + // a peer without ref tracking for this type sends NOT_NULL; reserve a placeholder so a + // read implementation that invokes `readContext.reference` stays balanced + readContext.preserveNotNullValueRefId(nextReadRefId, this); obj = read(readContext); readContext.setReadRef(nextReadRefId, obj); return obj; diff --git a/java/fory-core/src/main/java/org/apache/fory/serializer/UnionSerializer.java b/java/fory-core/src/main/java/org/apache/fory/serializer/UnionSerializer.java index aadd270719..5c5aae96eb 100644 --- a/java/fory-core/src/main/java/org/apache/fory/serializer/UnionSerializer.java +++ b/java/fory-core/src/main/java/org/apache/fory/serializer/UnionSerializer.java @@ -179,9 +179,11 @@ public Union read(ReadContext readContext) { TypeInfo readTypeInfo = resolver.readTypeInfo(readContext, declared); Serializer serializer = getCaseSerializer(caseId, readTypeInfo.getTypeId(), declared); GenericType genericType = getCaseGenericType(caseId, readTypeInfo.getTypeId()); + readContext.preserveNotNullValueRefId(nextReadRefId, serializer); caseValue = readCaseValue(readContext, serializer, genericType); } else { TypeInfo readTypeInfo = resolver.readTypeInfo(readContext); + readContext.preserveNotNullValueRefId(nextReadRefId, readTypeInfo.getSerializer()); caseValue = Serializers.read(readContext, readTypeInfo.getSerializer()); } readContext.setReadRef(nextReadRefId, caseValue); @@ -341,6 +343,7 @@ public static Object readCaseValue( readTypeInfo = resolver.readTypeInfo(readContext); } Serializer serializer = getCaseSerializer(fieldInfo, readTypeInfo.getTypeId(), readTypeInfo); + readContext.preserveNotNullValueRefId(nextReadRefId, serializer); Object caseValue = readCaseValue( readContext, serializer, getCaseGenericType(fieldInfo, readTypeInfo.getTypeId())); diff --git a/java/fory-core/src/main/java/org/apache/fory/serializer/UnknownCaseSerializer.java b/java/fory-core/src/main/java/org/apache/fory/serializer/UnknownCaseSerializer.java index d029fe5e5d..1fb468fc95 100644 --- a/java/fory-core/src/main/java/org/apache/fory/serializer/UnknownCaseSerializer.java +++ b/java/fory-core/src/main/java/org/apache/fory/serializer/UnknownCaseSerializer.java @@ -52,6 +52,7 @@ public static UnknownCase readPayload(ReadContext readContext, int caseId) { // UnknownCase owns only the union payload envelope. It does not create a separate // depth frame; nested payload serializers and the root-context reset own depth state. TypeInfo typeInfo = readContext.getTypeResolver().readTypeInfo(readContext); + readContext.preserveNotNullValueRefId(nextReadRefId, typeInfo.getSerializer()); Object value = readContext.readNonRef(typeInfo); readContext.setReadRef(nextReadRefId, value); return UnknownCase.ofRuntime(caseId, typeInfo.getTypeId(), value); diff --git a/java/fory-core/src/test/java/org/apache/fory/serializer/NotNullValueRefTrackingTest.java b/java/fory-core/src/test/java/org/apache/fory/serializer/NotNullValueRefTrackingTest.java new file mode 100644 index 0000000000..e75a9caad1 --- /dev/null +++ b/java/fory-core/src/test/java/org/apache/fory/serializer/NotNullValueRefTrackingTest.java @@ -0,0 +1,205 @@ +/* + * 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. + */ + +package org.apache.fory.serializer; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertSame; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.apache.fory.Fory; +import org.apache.fory.ForyTestBase; +import org.testng.annotations.Test; + +/** + * Reading {@code NOT_NULL_VALUE_FLAG} payloads with a reference-tracking reader. + * + *

A peer may disable reference tracking globally or per type and legitimately send {@code + * NOT_NULL} headers where a tracking reader preserved no read ref id. Reads that bind instances + * through {@code ReadContext#reference} must stay balanced on such input instead of popping an + * outer frame's id or underflowing the ref id stack. + */ +public class NotNullValueRefTrackingTest extends ForyTestBase { + + private static Fory fory(boolean trackingRef, boolean codegen) { + return builder().withRefTracking(trackingRef).withCodegen(codegen).build(); + } + + public static class Inner implements Serializable { + public int id; + public String name; + + public Inner() {} + + public Inner(int id, String name) { + this.id = id; + this.name = name; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof Inner)) { + return false; + } + Inner inner = (Inner) o; + return id == inner.id && Objects.equals(name, inner.name); + } + + @Override + public int hashCode() { + return Objects.hash(id, name); + } + } + + public static class Outer implements Serializable { + public String label; + public Inner inner; + public List inners; + public Map innerMap; + + @Override + public boolean equals(Object o) { + if (!(o instanceof Outer)) { + return false; + } + Outer outer = (Outer) o; + return Objects.equals(label, outer.label) + && Objects.equals(inner, outer.inner) + && Objects.equals(inners, outer.inners) + && Objects.equals(innerMap, outer.innerMap); + } + + @Override + public int hashCode() { + return Objects.hash(label, inner, inners, innerMap); + } + } + + public static class PolymorphicHolder implements Serializable { + public Object stringValue; + public Object numberValue; + public Object structValue; + + @Override + public boolean equals(Object o) { + if (!(o instanceof PolymorphicHolder)) { + return false; + } + PolymorphicHolder holder = (PolymorphicHolder) o; + return Objects.equals(stringValue, holder.stringValue) + && Objects.equals(numberValue, holder.numberValue) + && Objects.equals(structValue, holder.structValue); + } + + @Override + public int hashCode() { + return Objects.hash(stringValue, numberValue, structValue); + } + } + + public static class SelfReferencing implements Serializable { + public String name; + public SelfReferencing self; + public Inner shared1; + public Inner shared2; + } + + private static Outer newOuter() { + Outer outer = new Outer(); + outer.label = "outer"; + outer.inner = new Inner(1, "a"); + outer.inners = new ArrayList<>(Arrays.asList(new Inner(2, "b"), new Inner(3, "c"))); + outer.innerMap = new HashMap<>(); + outer.innerMap.put("k", new Inner(4, "d")); + return outer; + } + + @Test(dataProvider = "enableCodegen") + public void testStructFromUntrackedPeer(boolean codegen) { + Fory writer = fory(false, codegen); + Fory reader = fory(true, codegen); + Outer outer = newOuter(); + assertEquals(reader.deserialize(writer.serialize(outer)), outer); + } + + @Test(dataProvider = "enableCodegen") + public void testTypedRootFromUntrackedPeer(boolean codegen) { + Fory writer = fory(false, codegen); + Fory reader = fory(true, codegen); + Outer outer = newOuter(); + assertEquals(reader.deserialize(writer.serialize(outer), Outer.class), outer); + } + + @Test(dataProvider = "enableCodegen") + public void testContainerRootsFromUntrackedPeer(boolean codegen) { + Fory writer = fory(false, codegen); + Fory reader = fory(true, codegen); + List list = new ArrayList<>(Arrays.asList(new Inner(1, "a"), new Inner(2, "b"))); + assertEquals(reader.deserialize(writer.serialize(list)), list); + Map map = new HashMap<>(); + map.put("k1", new Inner(3, "c")); + map.put("k2", new Inner(4, "d")); + assertEquals(reader.deserialize(writer.serialize(map)), map); + Object[] array = new Object[] {new Inner(5, "e"), "plain", 42}; + assertEquals(reader.deserialize(writer.serialize(array)), array); + } + + @Test(dataProvider = "enableCodegen") + public void testPolymorphicFieldsFromUntrackedPeer(boolean codegen) { + Fory writer = fory(false, codegen); + Fory reader = fory(true, codegen); + PolymorphicHolder holder = new PolymorphicHolder(); + holder.stringValue = "value"; + holder.numberValue = 42L; + holder.structValue = new Inner(1, "a"); + assertEquals(reader.deserialize(writer.serialize(holder)), holder); + } + + @Test(dataProvider = "enableCodegen") + public void testSharedAndCircularRefsStillTracked(boolean codegen) { + Fory fory = fory(true, codegen); + SelfReferencing bean = new SelfReferencing(); + bean.name = "root"; + bean.self = bean; + Inner shared = new Inner(1, "shared"); + bean.shared1 = shared; + bean.shared2 = shared; + SelfReferencing read = (SelfReferencing) fory.deserialize(fory.serialize(bean)); + assertEquals(read.name, "root"); + assertSame(read.self, read); + assertSame(read.shared1, read.shared2); + assertEquals(read.shared1, shared); + } + + @Test + public void testXlangStructFromUntrackedPeer() { + Fory writer = Fory.builder().withXlang(true).withRefTracking(false).build(); + writer.register(Inner.class, 101); + Fory reader = Fory.builder().withXlang(true).withRefTracking(true).build(); + reader.register(Inner.class, 101); + Inner inner = new Inner(7, "xlang"); + assertEquals(reader.deserialize(writer.serialize(inner)), inner); + } +}