Swift Buildのダイアモンド問題解決を修正するPRがマージされた

#1424

はじめに

Swift Buildには、特定の依存関係でビルドすることで、シンボルが重複し型同一性が壊れるケースがありました。

私はこの問題の最小再現プロジェクトを用意し、原因を調査し修正PRを出していたのが解決されました。

Fix package diamond resolution by removing package target filtering

https://github.com/swiftlang/swift-build/pull/1424

おそらく、Xcode 27正式版でこのSwift Buildへの変更が反映されるはずで、 まだ現在のXcode 26やXcode 27βでもこの問題は再現するはずです。

どういう問題が起こるのか

Swift Buildでは依存関係の設定の仕方によって、 ビルド後にシンボルが重複する状態が残ってもビルドエラーにならないケースがありました。

シンボルが重複すると、同じモジュール名と型名を持つ型がアプリ内に複数存在します。 Swiftのソースコード上では同じ型に見えても、実行時には別の型として扱われることがあります。

この状態では、代表的には次のような問題が起きます。

  • キャストが失敗する
    • error as? Failurenil になる
    • catch Failure でエラーを捕捉できない
  • シングルトンパターンが成立しない
    • 同じ型名の型が複数存在するため、型ごとに別のstatic領域を持つ
    • その結果、重複した型の数だけ処理が実行される可能性がある

同じ型名でも別の型として扱われる理由は、Swiftが型の同一性を型メタデータで判定するためです。 同じ型名でも、静的リンクされたコピーが2つあると、型メタデータのアドレスが2つできます。

具体的にはas?catch は、型メタデータを使って型を照合します。 そのため、ソースコード上は同じ型名でも、型メタデータが別なら別型として扱われます。 結果として、error as? Failurenil になり、catch Failure も一致しません。

型が別物となる具体例

説明のためいきなり、具体例を示します。

次のようなPackage.swiftがあるという想定です。 これはPackageProductとして2つのLibraryPackageLibProductPackageLibProduct2を公開しているとします。

let package = Package(
    name: "Modules",
    products: [
        .library(
            name: "PackageLibProduct",
            targets: [
                "PackageLib",
            ]
        ),
        .library(
            name: "PackageLibProduct2",
            targets: [
                "PackageLib2",
            ]
        ),
    ],
    targets: [
        .target(
            name: "PackageLib",
            dependencies: [
                "Common"
            ],
        ),
        .target(
            name: "PackageLib2",
            dependencies: [
                "Common"
            ],
        ),
        .target(
            name: "Common"
        ),
    ]
)

これをアプリからリンクするわけですが、 アプリにはEmbeddedFrameworkとしてiOSFrameworkというDynamic LinkのTargetがあるとしましょう。

App
└── iOSFramework

これに対して先ほどのPackage.swiftでライブラリを用意します。

App
├── iOSFramework
│   └── PackageLibProduct // これは .libraryでありPackage Product
│       └── PackageLib
│           └── Common
│
├── PackageLibProduct // これは .libraryでありPackage Product
│   └── PackageLib
│       └── Common
│
└── PackageLibProduct2 // これは .libraryでありPackage Product
    └── PackageLib2
        └── Common

CommonというTargetがいろんな経路から依存されています。 エラーのハンドリングが失敗する例のコードを用意します。

Commonはエラーしか持っておらず

// Commonモジュール
public enum Failure: Error { case one }

iOSFrameworkモジュールはCommonターゲットをimportしエラーを出すだけです。

// iOSFrameworkモジュール
internal import Common

public func fail() throws {
    throw Common.Failure.one
}

App側からはiOSFrameworkモジュールを経由してCommonのエラーをハンドリングしようとします。

import iOSFramework
import Common

do {
  // Common.Failureをthrowする
  try iOSFramework.fail()
} catch let error as Common.Failure {
  // ここが実行されるはずだが、されない

} catch {
  // ここが実行されるが、
  // iOSFramework経由のCommon.Failureがここに来ている。
  // コード上でcatch let error as Common.Failureしているのは別の型。
}

しかし、 このコードではエラーであるCommon.Failureはハンドリングできません。 型として同一ではないという判断がされるためです。

問題が起こる原因

本質的にはループを抜けてしまうことが原因ではあるのですが、 そもそもなぜダイアモンド問題を解決できないのかというと、 Package ProductがDynamic判定された場合のそれを構成するTargetの除外処理でした。

具体的には、Swift Buildには次の配列がありました。

var packageTargetsToSkip = [SWBCore.Target]()

この配列には、Dynamic化されたPackage Productを構成するPackage Targetが入ります。 配列に入ったPackage Targetは、後続のDynamic判定候補から外れるわけです。

先ほどの実例を示すとPackageLibProductがDynamic Linkにするべきと判定され、 それを構成するPackageLibと依存するCommonがDynamic判定から外されます。

App
├── iOSFramework
│   └── PackageLibProduct ✅ これがDynamic化対象のPackage Product
│       └── PackageLib 🧨 Dynamic判定から除外される
│           └── Common 🧨 Dynamic判定から除外される
│
├── PackageLibProduct ✅ これがDynamic化対象のPackage Product
│   └── PackageLib 🧨 Dynamic判定から除外される
│       └── Common 🧨 Dynamic判定から除外される
│
└── PackageLibProduct2
    └── PackageLib2 // 別に重複していないのでStaticでいい
        └── Common  // Common自体は複数回登場しているためDynamic判定

この除外により、 PackageLibの配下のCommonまで検出対象から外れていました。 これによってCommonがDynamicではなくStaticなものがリンクされます。

上記によりTop-levelなPackage Productの配下をループしようとするとスキップされるのですが、 PackageLibProduct2の配下にあるCommonを元として重複するループを探す処理が動作もします。 その際にPackageLibProductにあるCommonと重複していることもわかり、 PackageLibProduct2のCommonはDynamic判定されます。 そのためStaticなCommonとDynamicなCommonの2つが誕生してしまいます。

(ここら辺は私の記憶が間違ってるかもしれません)

単純な構造ならそうはならない

もし単純な依存関係であれば、このような問題は起こりません。

App
├── PackageLibProduct
│   └── PackageLib // 重複していないのでStatic
│       └── Common // Common自体は複数回登場しているのでDynamic判定
│
└── PackageLibProduct2
    └── PackageLib2 // 別に重複していないのでStaticでいい
        └── Common  // Common自体は複数回登場しているためDynamic判定

ですが上記の例のAppとiOSFrameworkのような複数のtop-level linking targetがあると、 複数経路でPackage ProductをDynamic化することがあるため、 問題が起こったわけです。

top-level linking targetとは、たとえば次のようなTargetです。

  • App
  • Appが依存するDynamic LinkなTarget

AppとEmbedded Frameworkが共通のPackage Productを持つことは自然に起きます。

そして、その共通Package Targetが packageTargetsToSkip によって除外されると、 同じPackage Targetが別々のCommonとして分かれて同じプロセスに入るというわけです。

おわりに

根本的な問題としてSwift Buildはダイアモンド問題の問題が解決していないのに、無限ループをさせないようにして処理を抜けてしまうという不具合はまだ残っています。そのため、皆さんの環境でも実際はダイアモンド問題が解決されておらず、シンボル重複が起こっている可能性はあります。このPRでシンボル重複しなくなったおかげで別の問題が発覚するようになったIssueやPRもたくさんあります。おもしろいですね。

https://github.com/swiftlang/swift-build/issues/1482

https://github.com/swiftlang/swift-build/pull/1515