From c09c7a088174b2a37633d121fbeafd4a5fa70f6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A5ns=20Bernhardt?= Date: Mon, 14 Sep 2026 10:52:41 +0200 Subject: [PATCH] Keep the `get` read accessor for function-typed @Model properties on Swift 6.4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 6.3 IRGen crash (`ScalarPairTypeInfo::loadAsTake` when a `_read` coroutine yields a closure whose signature carries an indirectly-passed struct) is not fixed in Swift 6.4 / Xcode 27.0: `@Model struct M { var make: @Sendable (String) -> SixFieldStruct }` still SIGSEGVs swift-frontend in a debug build. The `#if compiler(>=6.4)` gate that re-enabled `_read` for function types therefore turned every such client into a compile failure on the new toolchain. Drop the gate so the `get` carve-out applies on every compiler, un-gate the macro snapshot test, and add the CHANGELOG entry. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 4 ++ .../SwiftModelMacros/ModelTrackedMacro.swift | 39 ++++++++----------- .../ModelMacroTests.swift | 7 +--- 3 files changed, 21 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a018e80..c9d880a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ All notable changes are documented here. The format follows [Keep a Changelog](h ## [Unreleased] +### Fixed + +- **`@Model` no longer crashes the Swift 6.4 (Xcode 27.0) compiler for closure-typed properties.** 1.0.0 gave function-typed `@Model` properties a plain `get` read accessor instead of a `_read` coroutine to work around a swift-frontend 6.3 IRGen crash (`ScalarPairTypeInfo::loadAsTake`) when the closure's signature carries a struct passed indirectly — more than four fields — in `-Onone` builds. That carve-out was gated on `#if !compiler(>=6.4)` on the assumption the bug would be fixed in 6.4. It was not: Swift 6.4 SIGSEGVs with the same signature on `@Model struct M { var make: @Sendable (String) -> SixFieldStruct }`, so any debug build of a client with such a property failed to compile on Xcode 27. The gate is removed; the `get` now applies on every compiler version. Observation is unchanged (the `get` calls the same `_$modelSource[read:]` subscript), and the write path (`_modify`, which yields an address rather than a value) never had the problem. + --- ## [1.0.18] — Cross-tree model-dependency deadlock fix diff --git a/Sources/SwiftModelMacros/ModelTrackedMacro.swift b/Sources/SwiftModelMacros/ModelTrackedMacro.swift index 6574f77..5fcf694 100644 --- a/Sources/SwiftModelMacros/ModelTrackedMacro.swift +++ b/Sources/SwiftModelMacros/ModelTrackedMacro.swift @@ -34,25 +34,24 @@ import SwiftSyntaxMacros /// expander only — the compiler keeps the initializer, and the init accessors still fire for /// defaulted properties (pinned at runtime by `PendingConstructionBoundaryTests`). /// -/// `isFunctionType` properties get a plain `get` instead of `_read` **when this macro plugin -/// is built with Swift 6.3**: swift-frontend 6.3 SIGSEGVs during IRGen when emitting a `_read` -/// (yield-once) coroutine that yields a *function value by value* whose parameter is passed -/// indirectly by the Swift calling convention (an aggregate of >4 fields). This is a -/// platform-general `-Onone` bug — reproduced on both `aarch64-unknown-linux-android28` and -/// `arm64-apple-macosx` debug builds (optimised builds inline the coroutine away and don't -/// crash, which is why it first surfaced only in Imagien's debug Android cross-compile). -/// A plain `get` (which returns a copy of the closure — trivial for a 2-word value) sidesteps it; -/// it calls the same `_$modelSource[read:]` subscript, so observation is unchanged, and the write -/// path (`_modify`, which yields an *address*, not a value) is unaffected. +/// `isFunctionType` properties get a plain `get` instead of `_read`: swift-frontend SIGSEGVs +/// during IRGen when emitting a `_read` (yield-once) coroutine that yields a *function value by +/// value* whose parameter or result is passed indirectly by the Swift calling convention (an +/// aggregate of >4 fields). This is a platform-general `-Onone` bug — reproduced on both +/// `aarch64-unknown-linux-android28` and `arm64-apple-macosx` debug builds (optimised builds +/// inline the coroutine away and don't crash, which is why it first surfaced only in Imagien's +/// debug Android cross-compile). A plain `get` (which returns a copy of the closure — trivial for +/// a 2-word value) sidesteps it; it calls the same `_$modelSource[read:]` subscript, so observation +/// is unchanged, and the write path (`_modify`, which yields an *address*, not a value) is unaffected. /// -/// The carve-out is gated on `#if compiler(>=6.4)` — i.e. on the Swift version, not the platform. +/// The carve-out applies on every compiler version. It was originally gated to Swift 6.3 on the +/// assumption the bug would be fixed in 6.4, but Swift 6.4 (Xcode 27.0) still crashes with the +/// same `ScalarPairTypeInfo::loadAsTake` signature, and a version gate that +/// re-enables the crash on every new toolchain is worse than a `get` that costs a closure copy. /// It can't be scoped per-target anyway (the macro runs on the host and can't see the target, and -/// `#if` can't switch between accessor *kinds* inside an accessor block), and it shouldn't be: -/// the bug isn't Android-specific, so the `get` correctly applies on every platform under 6.3 -/// (harmless — a closure copy is ~free). Under Swift 6.4+ we emit `_read` again, unconditionally: -/// if the bug was fixed this restores borrow-without-copy uniformly; if it regressed, debug builds -/// crash here and surface it, rather than the workaround being carried silently forever. Delete -/// this gate (and `isFunctionType`) once 6.3 support is dropped or the fix is confirmed. +/// `#if` can't switch between accessor *kinds* inside an accessor block), and it shouldn't be: the +/// bug isn't platform-specific. Revisit only once a toolchain is confirmed to compile the +/// function-typed `_read` (see `testModelClosureProperty` for the expansion). private func makeGetSet( identifier: String, index: Int, @@ -72,11 +71,6 @@ private func makeGetSet( """ let readAccessor: AccessorDeclSyntax - #if compiler(>=6.4) - // Swift 6.4+: emit the coroutine unconditionally (function types included). `isFunctionType` - // is intentionally unused here — the carve-out below only exists for the 6.3 compiler bug. - readAccessor = readCoroutine - #else if isFunctionType { readAccessor = """ @@ -87,7 +81,6 @@ private func makeGetSet( } else { readAccessor = readCoroutine } - #endif let writeAccessor: AccessorDeclSyntax if didSet != nil || willSet != nil { diff --git a/Tests/SwiftModelMacroTests/ModelMacroTests.swift b/Tests/SwiftModelMacroTests/ModelMacroTests.swift index 50e4e42..ac29f5d 100644 --- a/Tests/SwiftModelMacroTests/ModelMacroTests.swift +++ b/Tests/SwiftModelMacroTests/ModelMacroTests.swift @@ -171,12 +171,9 @@ struct ModelMacroTests { } } - // The `get`-for-function-types carve-out is gated on Swift 6.3 (see ModelTrackedMacro.makeGetSet); - // under 6.4+ the macro emits `_read` again, so this expectation only holds while built with 6.3. - #if !compiler(>=6.4) /// Function-typed stored properties get a plain `get` read accessor instead of `_read`. /// - /// swift-frontend 6.3 SIGSEGVs during IRGen when emitting a `_read` (yield-once) coroutine + /// swift-frontend (6.3 and 6.4 alike) SIGSEGVs during IRGen when emitting a `_read` (yield-once) coroutine /// that yields a function value by value whose parameter is passed indirectly — a platform-general /// `-Onone` bug (Apple + Android debug). The plain `get` (a copy) sidesteps it; the `nonmutating /// _modify` write path — which yields an address, not a value — is unchanged. See `ModelTrackedMacro.makeGetSet`. @@ -300,8 +297,6 @@ struct ModelMacroTests { } } - #endif // !compiler(>=6.4) - /// Model with an explicit type annotation but no default (requires user init). @Test func testModelCustomInit() { assertMacro {