Представьте ситуацию: вы работаете в огромном проекте, где количество модулей давно перевалило за тысячу. Вы решаете обновиться до свежего Xcode 26.2, ожидая прироста производительности, но вместо заветного «Build Succeeded» получаете молчаливое падение: SWBBuildService quit unexpectedly.

Всем привет, меня зовут Алексей Севко, я ведущий разработчик программного обеспечения из команды Delivery & Performance Яндекс Go. В этой статье я расскажу почти детективную историю о том, как:

  • Искать иголку в стоге сена: когда падает закрытый бинарник Xcode.

  • Стать контрибьютором swift‑build: почему иногда проще переписать системный поиск макросов в swift‑build, чем ждать фикса от Apple.

  • Использовать свою версию билд‑системы: как мы внедрили инфраструктуру прозрачной подмены компонентов Xcode через XCBBUILDSERVICE_PATH, чтобы не ждать релиза Xcode со Swift 6.3 и работать стабильно уже сегодня.

Если ваш проект тоже перерос стандартные инструменты Apple или вам просто интересно, как превратить рекурсию в итерацию и не сойти с ума от 45-минутных дебаг‑сессий, — эта статья для вас!


Предисловие: плавающие ошибки и магия линковки

В приложении Яндекс Go более 500 внутренних модулей, свыше 50 внешних зависимостей, NSE и виджеты. Подобные масштабы сильно отличаются от типовых приложений, на которых в Apple обычно тестируют работоспособность систем сборки.

Первые серьёзные звоночки прозвучали ещё при миграции на Xcode 16. Мы столкнулись с ошибкой SWBBuildService quit unexpectedly. Причём поведение было довольно странное: основной таргет собирался без проблем, а ошибка появлялась постоянно только при сборке тестов. Тогда найти конкретную причину не удалось, а падение прекратилось после перехода с динамической линковки на статическую. Забегая вперёд, отмечу: сама линковка тут была совершенно ни при чём.

После этого переход с версии 16.2 на 16.4 прошёл гладко, и казалось, что проблема отступила. Однако вскоре ошибки вернулись и стали носить плавающий характер: алерт SWBBuildService quit unexpectedly мог выскочить как во время активной сборки, так и просто в моменты, когда проект был открыт в IDE.

Иногда падения происходили при сборке проекта:

Диагностика падений

Стектрейс падения давал крайне мало зацепок о том, что именно в конфигурации нашего проекта приводило к крэшу. Всё сводилось к рекурсивным вызовам при вычислении значений макросов в недрах системы сборки:

  • MacroEvaluationContext.nextValueForMacro

  • MacroEvaluationProgram.executeInContext:

Причины оставались туманными, но само явление оказалось не уникальным. На официальном форуме Apple обнаружились треды вроде «Xcode 15.3 / XCBuildService Crash — Infinite loop when building iOS Project». Выяснилось, что проблема не была специфична только для Xcode 16, на котором мы её поймали впервые: корни уходили глубже.

В ходе экспериментов коллеги нащупали первую важную закономерность: частота падений напрямую коррелировала с количеством таргетов в проекте.

Анализ исторических данных позволил нам предсказать, когда проект упрётся в критический лимит даже на текущих версиях Xcode. Цифры выглядели тревожно: если для Xcode 16.3 лимит составлял 1884 таргета, то для Xcode 26.0 он снизился до 1660.

Однако магия чисел на этом не заканчивалась. В некоторых случаях крэш происходил и при гораздо меньших значениях: например, на графе из 996 таргетов. Как выяснилось позже, само по себе количество — не единственный фактор; куда более важную роль играла специфическая конфигурация проекта.

Важное уточнение: под таргетами здесь подразумеваются все сущности в воркспейсе, участвующие в компиляции. Они определяются на этапе ComputeTargetDependencyGraph. Найти этот граф можно в полных логах сборки по строке: note: Target dependency graph (X targets).

Инструменты: заглядываем под капот

Сам по себе Xcode — это «чёрный ящик», и шансов разобраться в причинах падения закрытой системы было мало. Однако в феврале 2025 года ситуация изменилась: Apple выложила swift‑build в опенсорс (анонс «Evolving SwiftPM Builds with Swift Build»). Это открыло нам легальную возможность не только использовать альтернативную систему сборки, но и, что критически важно, дебажить её прямо в Xcode.

Чтобы запустить Xcode с использованием кастомного swift‑build, достаточно одной команды из репозитория проекта:

swift package --disable-sandbox launch-xcode

А для работы через терминал (xcodebuild) используется:

swift package --disable-sandbox run-xcodebuild

Процесс дебага системы сборки оказался на удивление прямолинейным. После запуска Xcode через swift‑build алгоритм был следующим:

  1. Открыть Package.swift самого проекта swift‑build.

  2. В меню выбрать Debug > Attach to Process by PID or Name....

  3. В списке процессов найти SWBBuildServiceBundle.

  4. Нажать Attach и дождаться подключения дебагера.

Теперь, когда у нас был доступ к внутренностям системы сборки в момент падения, оставалось только поймать тот самый бесконечный цикл.

Нашим главным приоритетом был переход на Xcode 26.2, поэтому критически важно было использовать именно ту версию swift‑build, которая вшита в эту редакцию IDE.

Чтобы не гадать с совместимостью, мы ориентировались на версию Swift, поставляемую с Xcode. В итоге мы зачекаутились в репозитории swift‑build на тег SWIFT-6.2.3-RELEASE, соответствующий нашей версии инструментов.

Лайфхак: если вы решите повторить этот путь, соответствие тега swift-build и версии Xcode всегда можно вычислить по версии Swift‑компилятора, который идёт в комплекте с вашей IDE.

Мы начали воспроизведение бага в «лабораторных» условиях:

  1. Запускаем Xcode через swift package --disable-sandbox launch-xcode.

  2. Открываем наш основной проект — Yandex Go.

  3. Параллельно открываем Package.swift самого swift‑build.

  4. Подключаемся дебагером к процессу SWBBuildServiceBundle.

  5. В проекте приложения жмём заветное CMD + R.

Оставалось только ждать. Через некоторое время Xcode замирает, и дебагер выкидывает долгожданное (в нашем случае) сообщение: EXEC_BAD_ACCESS.

Теперь, когда крэш воспроизводился со стопроцентной вероятностью, а перед глазами был исходный код системы сборки, казалось, что победа близка. Но реальность оказалась сложнее.

Пытаемся найти решение

Размотав стектрейс до начала, мы обнаружили, что всё начинается с функции analyseSettings(_, , ). Её задача — отрезолвить все настройки проекта, чтобы на их основе выдать предупреждения или ошибки в логах Xcode.

Углубившись в код, мы нашли конкретное место, где система спотыкалась: вызов scope.evaluateAsString(settingToVerify). Этот метод последовательно проверял следующие настройки:

Честно говоря, нам очень не хотелось патчить сам swift‑build. Первым делом мы попытались найти изъяны в собственной конфигурации проекта и исправить их малой кровью. Моё внимание сразу приковали те самые настройки из стектрейса.

В большинстве случаев они выглядят так: VALUE = <some_value> $(inherited).

Логика проста: чтобы вычислить итоговое значение дочерней настройки, системе сборки нужно сначала разрешить родительскую через $(inherited), и так далее по цепочке. Возникла гипотеза: а что, если мы просто уберём $(inherited), вдруг глубина графа уменьшится и всё заведётся само собой?

Под подозрением оказались xcconfig‑файлы. Масла в огонь подлил найденный в коде комментарий в исходниках Xcode: «Check if there were any changes in used xcconfigs» — казалось бы, зацепка.

Гипотеза с ручным исправлением настроек быстро рассыпалась: оказалось, что большинство из них генерируются системой сборки автоматически. У нас просто нет к ним прямого доступа в проекте, а значит, «починить» их простой заменой настроек в xcconfig невозможно. Пришлось возвращаться к дебагеру.

Двигаясь дальше по цепочке вызовов в стектрейсе, мы проваливаемся в метод MacroEvaluationProgram.executeInContext. Внутри него логика ветвится, но мы неизбежно попадаем в кейс evalNamedMacro.

Именно здесь система пытается разрешить строковое значение макроса по его имени. В нашем случае это выглядело как очень долгий бег по кругу: система пытается вычислить макрос, который для своего вычисления требует вычислить родительский макрос, который, в свою очередь, требует... вычислить родительский макрос.

Путешествие по цепочке вызовов привело нас в метод lookupMacroInContext. Именно здесь система сборки пытается найти декларацию конкретного макроса, чтобы понять, как его интерпретировать.

Из lookupMacroInContext управление передаётся в lookupMacroDeclaration, который находится буквально в одном шаге от места падения — приватного метода _lookupMacroDeclaration.

На этом этапе диагноз стал очевиден: классическое переполнение стека (Stack Overflow). Казалось бы, решение на поверхности (найти и разорвать рекурсию), но в тот момент я решил пойти другим путём.

Раз у нас Stack Overflow, значит, стеку просто не хватает места для всех фреймов. А что, если просто… увеличить размер стека? ?

Я заметил, что крэш всегда происходит не в главном потоке. Полез в архивы документации Apple, чтобы освежить знания о лимитах в macOS, и наткнулся на следующие цифры:

Разница в 16 раз! Стало понятно, почему основной поток ещё как‑то держится, а фоновые сервисы сборки схлопываются при глубокой вложенности таргетов.

Гипотеза: если поднять размер стека для фоновых потоков хотя бы до уровня основного, мы сможем прожевать наш гигантский граф зависимостей без патчей.

Разница между лимитами впечатляла: 8 МБ для главного потока против всего 512 КБ для фоновых. Казалось бы, решение где‑то рядом. Сначала я пошёл проторенным путём и спросил у LLM, как увеличить. Нейросеть без зазрений совести обманула уверенно посоветовала использовать ulimit -s <value>, но это оказался тупик.

ulimit меняет общий лимит стека для всей программы, но никак не влияет на фиксированный размер стека отдельных потоков, которые программа порождает внутри себя. У класса Thread в Swift есть свойство stackSize, но задать его можно только в момент ручного создания потока. А лезть в логику управления потоками внутри всей системы сборки — задача со звёздочкой.

Я продолжил шерстить документацию и исходники Swift в поисках «магического флажка», который мог бы глобально увеличить стек для всех фоновых веток. Такого флага не существовало.

Однако поиски привели меня в репозиторий SwiftSyntax, где разработчики уже сталкивались с похожими проблемами при парсинге глубоко вложенных структур. Там я нашёл временное решение (workaround) для обхода Stack Overflow:

let work = DispatchWorkItem {
    doFormatting()
}
let thread = Thread {
    work.perform()
}
thread.stackSize = 8 << 20 // 8 MB.
thread.start()
work.wait()

Чтобы окончательно убедиться, что дело в размере стека, я решил применить решение из SwiftSyntax. Суть проста: упаковать опасный рекурсивный вызов в новый поток с принудительно увеличенным лимитом памяти.

Я добавил обёртку с созданием потока и увеличенным stackSize прямо в функцию analyseSettings(_, , ). Скрестив пальцы, запустил сборку...

И это сработало! Точнее, частично: этап анализа настроек успешно прошёл, не уронив систему сборки в привычном месте. Однако радость была недолгой: как только одна лазейка закрылась, тут же открылась следующая — Xcode снова упал, но уже в совершенно другом месте.

Недолго думая, я применил тот же трюк и там. Но стоило закрыть одну брешь, как падения начали всплывать в новых, самых неожиданных участках кода. Я продолжал методично оборачивать каждый проблемный вызов в защитный слой с расширенным stackSize, но список мест, где система сборки задыхалась от нехватки памяти, только рос.

Стало окончательно ясно: попытка вылечить Stack Overflow простым увеличением лимитов — это борьба с симптомами, а не с причиной болезни. 

Все падения
Какой в итоге получился дифф
diff --git a/Sources/SWBMacro/MacroEvaluationScope.swift b/Sources/SWBMacro/MacroEvaluationScope.swift
index 98d6e69..7a85a46 100644
--- a/Sources/SWBMacro/MacroEvaluationScope.swift
b/Sources/SWBMacro/MacroEvaluationScope.swift
@@ -103,9 +103,12 @@ public final class MacroEvaluationScope: Serializable, Sendable {
         }
 
         // Create an evaluation context and return the result of evaluating the expression as a string in that context.
        let resultBuilder = MacroEvaluationResultBuilder()
        expr.evaluate(context: MacroEvaluationContext(scope: self, lookup: lookup), resultBuilder: resultBuilder, alwaysEvalAsString: true)
        return resultBuilder.buildString()
        // Run on a thread with larger stack to avoid stack overflow in deeply nested macro expressions.
        return withLargeStackSync {
            let resultBuilder = MacroEvaluationResultBuilder()
            expr.evaluate(context: MacroEvaluationContext(scope: self, lookup: lookup), resultBuilder: resultBuilder, alwaysEvalAsString: true)
           return resultBuilder.buildString()
        }
     }
 
     /// Evaluate the given macro as a string (regardless of type) and return the result.
@@ -129,10 +132,13 @@ public final class MacroEvaluationScope: Serializable, Sendable {
             }
 
             // Otherwise we create an evaluation context and return the result of evaluating the expression as a string in that context.
           let context = MacroEvaluationContext(scope: self, macro: macro, value: value, lookup: lookup)
           let resultBuilder = MacroEvaluationResultBuilder()
           value.expression.evaluate(context: context, resultBuilder: resultBuilder, alwaysEvalAsString: true)
           return resultBuilder.buildString()
            // Run on a thread with larger stack to avoid stack overflow in deeply nested macro expressions.
            return withLargeStackSync {
                let context = MacroEvaluationContext(scope: self, macro: macro, value: value, lookup: lookup)
                let resultBuilder = MacroEvaluationResultBuilder()
                value.expression.evaluate(context: context, resultBuilder: resultBuilder, alwaysEvalAsString: true)
                return resultBuilder.buildString()
            }
         }
 
         if lookup == nil {
@@ -192,9 +198,12 @@ public final class MacroEvaluationScope: Serializable, Sendable {
         MacroEvaluationScope.exprEvaluations.increment()
 
         // Create an evaluation context and return the result of evaluating the expression as its native type (string or string list) in that context.
        let resultBuilder = MacroEvaluationResultBuilder()
        expr.evaluate(context: MacroEvaluationContext(scope: self, lookup: lookup), resultBuilder: resultBuilder)
        return resultBuilder.buildStringList()
        // Run on a thread with larger stack to avoid stack overflow in deeply nested macro expressions.
        return withLargeStackSync {
            let resultBuilder = MacroEvaluationResultBuilder()
            expr.evaluate(context: MacroEvaluationContext(scope: self, lookup: lookup), resultBuilder: resultBuilder)
            return resultBuilder.buildStringList()
        }
     }
 
     /// Evaluate the given string list macro and return the result.
@@ -212,10 +221,13 @@ public final class MacroEvaluationScope: Serializable, Sendable {
                 return []
             }
             // Otherwise we create an evaluation context and return the result of evaluating the expression as its native type (string or string list) in that context.
            let context = MacroEvaluationContext(scope: self, macro: macro, value: value, lookup: lookup)
            let resultBuilder = MacroEvaluationResultBuilder()
            value.expression.evaluate(context: context, resultBuilder: resultBuilder)
            return resultBuilder.buildStringList()
            // Run on a thread with larger stack to avoid stack overflow in deeply nested macro expressions.
            return withLargeStackSync {
                let context = MacroEvaluationContext(scope: self, macro: macro, value: value, lookup: lookup)
                let resultBuilder = MacroEvaluationResultBuilder()
                value.expression.evaluate(context: context, resultBuilder: resultBuilder)
                return resultBuilder.buildStringList()
            }
         }
 
         if lookup == nil {
@@ -245,10 +257,13 @@ public final class MacroEvaluationScope: Serializable, Sendable {
                 return []
             }
             // Otherwise we create an evaluation context and return the result of evaluating the expression as its native type (string or string list) in that context.
            let context = MacroEvaluationContext(scope: self, macro: macro, value: value, lookup: lookup)
            let resultBuilder = MacroEvaluationResultBuilder()
            value.expression.evaluate(context: context, resultBuilder: resultBuilder)
            return resultBuilder.buildStringList().map { Path($0).normalize().str }
            // Run on a thread with larger stack to avoid stack overflow in deeply nested macro expressions.
            return withLargeStackSync {
                let context = MacroEvaluationContext(scope: self, macro: macro, value: value, lookup: lookup)
                let resultBuilder = MacroEvaluationResultBuilder()
                value.expression.evaluate(context: context, resultBuilder: resultBuilder)
                return resultBuilder.buildStringList().map { Path($0).normalize().str }
            }
         }
 
         if lookup == nil {
@@ -373,8 +388,11 @@ public func evaluateAsString(_ asgn: MacroValueAssignment, macro: MacroDeclarati
     }
 
     // Create an evaluation context and return the result of evaluating the expression as its native type (string or string list) in that context.
    let resultBuilder = MacroEvaluationResultBuilder()
    let context = MacroEvaluationContext(scope: scope, macro: macro, value: asgn, lookup: lookup)
    expr.evaluate(context: context, resultBuilder: resultBuilder, alwaysEvalAsString: true)
    return resultBuilder.buildString()
    // Run on a thread with larger stack to avoid stack overflow in deeply nested macro expressions.
    return withLargeStackSync {
        let resultBuilder = MacroEvaluationResultBuilder()
        let context = MacroEvaluationContext(scope: scope, macro: macro, value: asgn, lookup: lookup)
        expr.evaluate(context: context, resultBuilder: resultBuilder, alwaysEvalAsString: true)
        return resultBuilder.buildString()
    }
 }
diff --git a/Sources/SWBMacro/MacroNamespace.swift b/Sources/SWBMacro/MacroNamespace.swift
index eb42c54..0fbc4e0 100644
--- a/Sources/SWBMacro/MacroNamespace.swift
+++ b/Sources/SWBMacro/MacroNamespace.swift
@@ -43,9 +43,15 @@ public final class MacroNamespace: CustomDebugStringConvertible, Encodable, Send
 
     /// Looks up and returns the macro declaration that's associated with 'name', if any.  The name is not allowed to be the empty string.
     public func lookupMacroDeclaration(_ name: String) -> MacroDeclaration? {
        return macroRegistry.withLock { macroRegistry in
            return lookupMacroDeclarationUnlocked(name, in: macroRegistry)
        precondition(name != "")
        var currentNamespace: MacroNamespace? = self
        while let namespace = currentNamespace {
            if let macroDecl = namespace.macroRegistry.withLock({ $0[name] }) {
                return macroDecl
            }
            currentNamespace = namespace.parentNamespace
         }
        return nil
     }
 
     /// Perform an unlocked macro lookup.
@@ -139,9 +145,15 @@ public final class MacroNamespace: CustomDebugStringConvertible, Encodable, Send
 
     /// Looks up and returns the macro condition parameter that's associated with 'name', if any.  The name is not allowed to be the empty string.
     public func lookupConditionParameter( name: String) -> MacroConditionParameter? {
        conditionParameters.withLock { conditionParameters in
            lookupConditionParameterUnlocked(name, in: conditionParameters)
        precondition(name != "")
        var currentNamespace: MacroNamespace? = self
        while let namespace = currentNamespace {
            if let condParam = namespace.conditionParameters.withLock({ $0[name] }) {
                return condParam
            }
            currentNamespace = namespace.parentNamespace
         }
        return nil
     }
 
     private func lookupConditionParameterUnlocked(_ name: String, in conditionParameters: [String: MacroConditionParameter]) -> MacroConditionParameter? {
diff --git a/Sources/SWBUtil/UserDefaults.swift b/Sources/SWBUtil/UserDefaults.swift
index e1a5876..2c5664c 100644
--- a/Sources/SWBUtil/UserDefaults.swift
+++ b/Sources/SWBUtil/UserDefaults.swift
@@ -409,3 +409,59 @@ extension Task where Failure == Never {
         }
     }
 }
import class Foundation.NSCondition
import class Foundation.DispatchSemaphore

private final class LargeStackThreadPool: @unchecked Sendable {
    static let shared = LargeStackThreadPool()

    private let poolSize: Int
    private let condition = NSCondition()
    private var workQueue: [() -> Void] = []
    private var threads: [Thread] = []

    private init(poolSize: Int = min(ProcessInfo.processInfo.activeProcessorCount, 10)) {
        self.poolSize = poolSize
        for i in 0..<poolSize {
            let thread = Thread { [weak self] in
                self?.workerLoop()
            }
            thread.qualityOfService = .userInitiated
            thread.stackSize = 5 << 20 // 5 mb
            thread.name = "com.apple.swift-build.large-stack-worker-\(i)"
            threads.append(thread)
            thread.start()
        }
    }

    private func workerLoop() {
        while true {
            condition.lock()
            while workQueue.isEmpty {
                condition.wait()
            }
            let work = workQueue.removeFirst()
            condition.unlock()
            work()
        }
    }

    func execute<T>(_ block: @escaping () -> T) -> T {
        let semaphore = DispatchSemaphore(value: 0)
        var result: T!
        condition.lock()
        workQueue.append {
            result = block()
            semaphore.signal()
        }
        condition.signal()
        condition.unlock()
        semaphore.wait()
        return result
    }
}

public func withLargeStackSync<T>(_ block: @escaping () -> T) -> T {
    return LargeStackThreadPool.shared.execute(block)
}

Вновь запускаю сборку... И долгожданный момент — всё собралось!

Хотя временные костыли с потоками сработали, было очевидно: в таком виде фикс в продакшен не пойдёт. Я вернулся к исходной точке. Если мы не можем просто увеличить размер стека фоновых потоков, возможно, стоит уменьшить размер фреймов, которые кладутся в стек?

Идея была проста: если уменьшить размер фрейма (stack frame), то на тот же объём стека их поместится гораздо больше. Я начал препарировать сущности, которые участвуют в рекурсии при вызове lookupMacroDeclaration, надеясь найти среди них структуры, раздувающие стек.

Однако детальный аудит показал, что с размерами объектов всё было в порядке:

  • MacroNamespace представлял собой компактную структуру.

  • MacroRegistry — эффективно организованное хранилище.

  • В основном всё реализовано классами, у которых размер фиксирован.

Стало ясно: проблема не в том, что каждый шаг рекурсии слишком тяжёлый, а в том, что самих шагов в нашем гигантском проекте становится критически много. 

Size of MacroNamespace.Type: 8
Size of MacroEvaluationScope.Type: 8
Size of MacroEvaluationProgram.Type: 8
Size of MacroExpression.Type: 8
Size of MacroValueAssignmentTable.Type: 16
Size of MacroEvaluationResultBuilder.Type: 8
Size of MacroEvaluationContext.Type: 8
Size of EvalInstr.Type: 17

Моя гипотеза об аномально «толстых» объектах не подтвердилась. Почти везде в цепочке вызовов использовались классы с фиксированным размером в 8 байт. Из общего ряда выбивались лишь две сущности:

  • MacroValueAssignmentTable: 16 байт.

  • EvalInstr: 17 байт.

Стало окончательно ясно: проблема не в том, что каждый шаг рекурсии тяжёлый, а в том, что при нашем количестве модулей и нашей сложности графа этих шагов становится критически много для стандартного стека фонового потока.

Раз оптимизация данных не принесла плодов, я снова вернулся на шаг назад — к самому методу lookupMacroDeclaration. Именно тогда пришло понимание: единственный надежный способ победить Stack Overflow здесь — это полностью отказаться от рекурсивного спуска по иерархии пространств имён в пользу итеративного цикла.

Анализ стектрейса в дебагере наглядно показал рекурсию:

lookupMacroDeclaration_lookupMacroDeclarationlookupMacroDeclaration... и так до победного конца (точнее, до падения).

Как известно, любую рекурсию можно заменить циклом. И в этот момент возник логичный вопрос: «Почему я не сделал этого сразу?!» Ведь итеративный подход не плодит фреймы на стеке, а просто переиспользует текущий, перемещаясь по ссылкам в куче (heap).

После недолгих раздумий родился следующий патч для MacroNamespace.swift

Этот простой цикл полностью устранил риск переполнения стека при поиске деклараций макросов, независимо от глубины вложенности модулей в нашем проекте. Аналогичным образом мы переписали и поиск параметров условий (lookupConditionParameter).

Убрав часть патчей, я запустил сборку — и снова успех! Довольный, я пошёл делать PR в swift‑build, но тут меня ждало разочарование…

Смотря список открытых PR«ов, я увидел Fix stack overflow in macro namespace lookups

Весь год проблема висела мёртвым грузом, но, по закону подлости, именно в те несколько дней, когда я копался в исходниках, ситуация пришла в движение. Я проверял список фиксов в пятницу утром — всё было тихо. Свой PR я подготовил к вечеру понедельника, и каково же было моё удивление: тот самый «Fix stack overflow in macro namespace lookups» появился буквально в промежутке между моими проверками!

Это немного деморализует: чувствуешь себя первооткрывателем, который вышел на берег и увидел там свежие следы другого путешественника. Я решил не изобретать велосипед, накатил патч из этого PR (наши решения были практически идентичны) и запустил сборку.

Проект собрался. Казалось бы, пора выдыхать и расстроиться, что не удалось законтрибьютить в swift‑build праздновать? Но... я напрочь забыл про второй крэш.

Как только одна голова гидры (рекурсия в поисках макросов) была отрублена, дала о себе знать вторая — в методе MacroEvaluationProgram.executeInContext. Это означало, что одним итеративным циклом нам не отделаться.

Рано я начал расстраиваться из‑за того, что кто‑то меня опередил. Оказалось, что мой проект успешно собрался только потому, что я... не до конца выпилил свой предыдущий дифф с увеличением стека. Как только я полностью откатился к чистому коду с официальным патчем, всё снова посыпалось.

На душе сразу стало легче: мой вклад всё ещё критически важен. Стало очевидно, что патч исправляет лишь часть проблемы, но сама стадия вычисления в гигантских проектах всё так же неизбежно упирается в потолок в 512 КБ.

Пришлось снова вернуться в чертоги MacroEvaluationProgram.executeInContext. Если в предыдущем случае мы смогли заменить рекурсию циклом, то здесь нас ждал сюрприз: логика вычисления выражений была куда более ветвистой и сложной.

Анализ кода показал, что мы снова наступили на те же грабли: метод value.expression.evaluate(...) у MacroExpression по цепочке вызывал всё тот же MacroEvaluationProgram.executeInContext. Классическая рекурсия, которая в нашем графе зависимостей превращалась в бесконечную очень большую лестницу.

Конечно, можно было бы снова броситься в бой и патчить код на лету, но была одна проблема: сборка всего проекта в дебажной конфигурации занимала более 45 минут. Проверять гипотезы с такой скоростью — непозволительная роскошь.

Чтобы гарантированно победить баг и не сойти с ума от ожидания, я решил написать изолированный юнит‑тест. План был прост: создать синтетический пример с экстремальной вложенностью макросов, который гарантированно роняет систему сборки, и отлаживаться на нём.

Чуть поразмыслив, я получил следующий тест:

@Test
func deeplyNestedEvaluation() throws {
    let depth = 600
    var macros: [StringMacroDeclaration] = []

    let namespace = MacroNamespace(debugDescription: "test")
    var table = MacroValueAssignmentTable(namespace: namespace)

    let rootMacro = try namespace.declareStringMacro("MACRO_0")
    table.push(rootMacro, literal: "root")
    macros.append(rootMacro)

    for i in 1..<depth {
        let macro = try namespace.declareStringMacro("MACRO_\(i)")
        macros.append(macro)
        table.push(macro, namespace.parseString("$(MACRO_\(i-1))-\(i)"))
    }

    let scope = MacroEvaluationScope(table: table)

    let deepest = macros[depth - 1]
    let result = scope.evaluate(deepest)

    #expect(result == "root-\((1..<depth).map(String.init).joined(separator: "-"))")
}

Для воспроизведения бага я смоделировал ситуацию с глубокой вложенностью макросов. В реальном проекте это обычно бесконечные цепочки $(inherited), где значение дочернего макроса зависит от родительского, и так до самого корня. В тесте я заменил их синтетической последовательностью MACRO_i.

Когда тест подтвердил падение на чистом коде, настало время финальной переработки. Чтобы избавиться от рекурсии в MacroEvaluationProgram.executeInContext, я ввёл абстракцию EvaluationFrame.

Идея в том, чтобы превратить неявный стек вызовов функции, который ограничен системой, в явный стек объектов в оперативной памяти, который ограничен только размером вашей RAM.

// Use stack to avoid stack overflow when evaluating deeply nested macro expressions.
//
// Each frame on the stack represents an in-progress evaluation of a macro expression.
// When we need to evaluate a nested macro, instead of making a recursive call,
// we push the current state onto the stack and start evaluating the nested expression.
// When we complete an expression evaluation, we pop the stack and continue.
//
// We use a class so that we can share the subresults array between parent and child frames
// when the child needs to output to the parent's subresult buffer.
final class EvaluationFrame {
    let instructions: [EvalInstr]
    var instructionIndex: Int = 0
    let context: MacroEvaluationContext
    let resultBuilder: MacroEvaluationResultBuilder
    let alwaysEvalAsString: Bool
    // Stack of subresult buffers.  All instructions that affect buffers apply to the top one on this stack.
    var subresults: [MacroEvaluationResultBuilder] = []

    init(instructions: [EvalInstr], context: MacroEvaluationContext, resultBuilder: MacroEvaluationResultBuilder, alwaysEvalAsString: Bool) {
        self.instructions = instructions
        self.context = context
        self.resultBuilder = resultBuilder
        self.alwaysEvalAsString = alwaysEvalAsString
    }
}

Далее формируем стек и итерируемся по нему:

var stack: [EvaluationFrame] = [
    EvaluationFrame(
        instructions: instructions,
        context: context,
        resultBuilder: resultBuilder,
        alwaysEvalAsString: alwaysEvalAsString,
    )
]

while let frame = stack.last {
    guard frame.instructionIndex < frame.instructions.count else {
        // If any new subresult buffers were created (using the BeginSubresult instruction), they should have all been consumed again (using either EvalNamedMacro or MergeSubresult instructions) by now.
        assert(frame.subresults.isEmpty, "Subresult buffers should be consumed by the end of evaluation")
        stack.removeLast()
        continue
    }

    // Iterate over all the instructions in order.  We currently don't have any branching or conditional instructions, so this is very simple.
    let instr = frame.instructions[frame.instructionIndex]
    frame.instructionIndex += 1

Далее все обращения к subresults заменяем frame.subresults и доходим до места возникновения рекурсии: value.expression.evaluate(context:macro:value:parent). Здесь приходится немного продублировать код:

// We found a value, so we evaluate its associated "macro evaluation program" into it the topmost subresult buffer.  Note that multiple programs often contribute to the same buffer, e.g. in "$(X)/$(Y)".
// We don't use value.expression.evaluate(context:resultBuilder:alwaysEvalAsString) to avoid stack overflow when evaluating deeply nested macro expressions.
switch value.expression.evalProgram.variant {
case .empty:
    break
case .literal(let literalStr):
    (frame.subresults.last ?? frame.resultBuilder).append(literalStr)
case .instructions(let nestedInstructions):
    let nextFrame = EvaluationFrame(
        instructions: nestedInstructions,
        context: MacroEvaluationContext(scope: frame.context.scope, macro: macro, value: value, parent: frame.context),
        resultBuilder: frame.subresults.last ?? frame.resultBuilder,
        alwaysEvalAsString: asString || frame.alwaysEvalAsString,
    )
    // Frame will be processed in the next iteration. The current frame remains on the stack and will continue after the nested frame completes
    stack.append(nextFrame)
}

Момент истины: запускаем тесты

Запуск тестов... Секундное ожидание... Всё зелёное! Синтетический тест на экстремальную вложенность макросов, который раньше схлопывал стек за мгновения, теперь проходит штатно. Переход на явный стек фреймов в куче полностью решил проблему лимитов.

Но главный экзамен был впереди — наш реальный проект. Я полностью вычистил все временные правки, оставив только итеративный поиск и новую логику MacroEvaluationProgram. Запускаю сборку — успех! Проект собрался чисто, быстро и, что самое главное, предсказуемо.

Отправив PR, я замер в ожидании. Самым спорным моментом в моём решении было «опубличивание» (смена доступа на public) свойства evaluationProgram внутри MacroExpression. Я переживал, что мейнтейнеры сочтут это нарушением инкапсуляции и заставят переписывать всё заново.

Но ожидание было недолгим. Все сомнения развеялись, когда пришли первые отзывы:

Мои изменения приняли практически без правок. Видеть, как твой код становится частью фундаментального инструмента, которым пользуются миллионы разработчиков по всему миру, — это и есть та самая награда за бессонные ночи и 45-минутные дебаг‑сессии.

Спустя всего несколько дней мой Pull Request был официально влит в основную ветку swift‑build. Более того, фикс стал частью релизной ветки Swift 6.3. Это значит, что в будущих версиях Xcode проблема со Stack Overflow при вычислении макросов исчезнет для всех разработчиков в мире.

Интеграция: как подменить сердце Xcode

После того как мы получили исправленный бинарник swift‑build, возник закономерный вопрос: как заставить Xcode использовать нашу версию вместо стандартной? Нам нужно было закрыть два сценария:

  1. Локальная разработка: когда программист просто открывает проект в IDE или собирает его через xcodebuild.

  2. CI/CD: автоматизированная сборка на серверах.

Чтобы понять, как изящно подменить swift‑build, не ломая систему, мы заглянули в механизмы запуска самой системы сборки (например, в скрипты плагина launch‑xcode).

Выяснилось, что Xcode ищет компоненты системы сборки по определённым путям и через переменные окружения. Это открыло нам путь к интеграции без необходимости патчить каждый компьютер разработчика вручную.

Важный нюанс: подмена должна быть прозрачной, чтобы разработчик не думаль о том, какой swift‑build у него запущен, а система сама подхватывала нужную версию при открытии нашего гигантского монолита.

Архитектура подмены: от теории к практике

Изучив внутренности плагинов launch‑xcode, мы обнаружили волшебную переменную окружения XCBBUILDSERVICE_PATH. Именно она указывает Xcode, где искать SWBBuildServiceBundle. Если она задана, IDE игнорирует встроенный сервис и использует тот, что подложили мы.

Оставалось решить, как доставлять исправленный бинарник разработчикам. Мы рассматривали три варианта:

  1. Хранение в репозитории. Просто, но раздувает проект и ведёт к конфликтам при обновлениях.

  2. Пакет‑обёртка. Гибко, но требует дублирования обвязки в каждом новом проекте.

  3. Динамическая загрузка (наш выбор). Создание общего корпоративного решения, которое выкачивает нужную версию бинарника при первом обращении.

Чтобы подмена была прозрачной, мы внедрили прокси‑скрипты для стандартных команд open, xed и xcodebuild.

Алгоритм работы нашего open:

  1. Перехватываем команду. Если аргумент — .xcodeproj или .xcworkspace, идём дальше.

  2. Проверяем конфиг проекта: включён ли для него кастомный swift‑build.

  3. Сверяем версию Xcode. Если всё совпадает — запускаем наш внутренний инструмент SyncTool.

  4. SyncTool проверяет наличие собранного SWBBuildServiceBundle локально (или скачивает его).

  5. Наконец, мы проставляем путь к исправленному сервису в XCBBUILDSERVICE_PATH и запускаем настоящий Xcode.

Для xcodebuild схема аналогична, за исключением первого пункта. 

В итоге получилась директория SWBBuild со следующим содержимым:

  • swift-build.config. В нём определяется глобальный статус — включено ли использование кастомного swift‑build в данный момент — и задаются конкретные версии Xcode, для которых эта подмена актуальна.

    Однако мы предусмотрели и ручное управление: любые настройки из конфига могут быть переопределены через переменные окружения. Они имеют высший приоритет, что позволяет разработчику точечно изменять параметры сборки, не правя файл конфигурации в репозитории.

CUSTOM_SWIFT_BUILD_ENABLED=true
CUSTOM_SWIFT_BUILD_VERSION=6.2.3-0
CUSTOM_SWIFT_BUILD_XCODE_VERSIONS="26.2"
  • CUSTOM_SWIFT_BUILD_ENABLED — глобальный флаг, включающий или выключающий использование кастомного swift‑build.

  • CUSTOM_SWIFT_BUILD_XCODE_VERSIONS — белый список версий Xcode. Подмена сработает только в том случае, если текущая версия IDE совпадает с одной из указанных в этом списке.

  • CUSTOM_SWIFT_BUILD_VERSION — версия конкретного билда swift‑build, которую необходимо выкачать из нашего внутреннего хранилища. Мы используем формат <swift-version>-<internal-patch-number> (например, 6.0.2-patch1), чтобы точно сопоставлять патчи с апстримом.

Чтобы автоматизировать процесс на CI и в терминалах разработчиков, мы создали прокси‑скрипт xcodebuild. Это легковесная обвязка над стандартным /usr/bin/xcodebuild.

Логика проста: при запуске скрипт проверяет условия (включён ли флаг и подходит ли версия Xcode). Если условия соблюдены, он инициализирует загрузку нужного бинарника, проставляет XCBBUILDSERVICE_PATH и делегирует выполнение оригинальному системному бинарнику.

#!/bin/bash

set -e

XCODEBUILD_SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
declare -r XCODEBUILD_SCRIPT_DIR

. "$XCODEBUILD_SCRIPT_DIR/swift-build-common.sh"

if is_custom_swift_build_enabled; then
    . "$XCODEBUILD_SCRIPT_DIR/SWBBuildServiceBundle.sh"

    export XCBBUILDSERVICE_PATH="$SWBBuildServiceBundle" && /usr/bin/xcrun xcodebuild "$@"
    exit
fi

/usr/bin/xcrun xcodebuild "$@"

open — обвязка над стандартным /usr/bin/open, которая открывает Xcode с кастомным swift‑build.

#!/bin/bash

set -e

declare -r EXTENSION="${*##*.}" # this will work for most use cases 

if [[ "$EXTENSION" == xcodeproj || "$EXTENSION" == xcworkspace ]]; then
    OPEN_SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
    declare -r OPEN_SCRIPT_DIR

    . "$OPEN_SCRIPT_DIR/swift-build-common.sh"

    if is_custom_swift_build_enabled; then
        . "$OPEN_SCRIPT_DIR/SWBBuildServiceBundle.sh" "${SYNC_ARGS[@]}"

        /usr/bin/open -b -F --env XCBBUILDSERVICE_PATH="$SWBBuildServiceBundle" -b "com.apple.dt.Xcode" "$@"
        exit
    fi
fi

/usr/bin/open "$@"

xed — обвязка над стандартным /usr/bin/xed, которая открывает Xcode с кастомным swift‑build.

#!/bin/bash

set -e

XED_SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
declare -r XED_SCRIPT_DIR

. "$XED_SCRIPT_DIR/swift-build-common.sh"

if is_custom_swift_build_enabled; then
    . "$XED_SCRIPT_DIR/SWBBuildServiceBundle.sh"

    export XCBBUILDSERVICE_PATH="$SWBBuildServiceBundle" && /usr/bin/xed "$@"
    exit
fi

/usr/bin/xed "$@"

swift-build-common.sh — общая логика по проверке включённости кастомного swift‑build.

#!/bin/bash

# Prevents multiple sourcing
if [ -n "$SWIFT_BUILD_COMMON_DIR" ]; then
    return 0
fi

SWIFT_BUILD_COMMON_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
declare -r SWIFT_BUILD_COMMON_DIR

while IFS='=' read -r key value; do
    # Skip comments and empty lines
    if [[ $key =~ ^[[:space:]]*# ]] || [[ -z $key ]]; then
        continue
    fi

    value=$(echo "$value" | sed -e 's/^"//' -e 's/"$//' -e "s/^'//" -e "s/'$//")

    if [ -z "${!key}" ]; then
        export "$key=$value"
    fi
done < <(grep -v '^#' "$SWIFT_BUILD_COMMON_DIR/swift-build.config" | grep -v '^$')

is_custom_swift_build_enabled() {
    if [ "$CUSTOM_SWIFT_BUILD_ENABLED" != true ]; then
        return 1
    fi

    XCODE_VERSION=$(/usr/bin/xcrun xcodebuild -version 2>/dev/null | grep -E "^Xcode" | awk '{print $2}')

    if [[ "$CUSTOM_SWIFT_BUILD_XCODE_VERSIONS" =~ "$XCODE_VERSION" ]]; then
        return 0
    fi

    return 1
}

SWBBuildServiceBundle.sh — выкачивает нужную версию SWBBuildServiceBundle из s3 и экспортирует его в переменную окружения.

#!/bin/bash

# This script should be sourced, not executed!

set -e

SWB_BUILD_SERVICE_BUNDLE_SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
declare -r SWB_BUILD_SERVICE_BUNDLE_SCRIPT_DIR

. "$SWB_BUILD_SERVICE_BUNDLE_SCRIPT_DIR/swift-build-common.sh"

if [ -z "$CUSTOM_SWIFT_BUILD_VERSION" ]; then
    echo "swift-build version is not set, provide it via CUSTOMSWIFT_BUILD_VERSION" >&2
    exit 1
fi

ARCH="$(uname -m)"
declare -r ARCH
declare -r RELEASE_VERSION="$CUSTOM_SWIFT_BUILD_VERSION-macos-$ARCH"

declare -r TOOL_NAME="swift-build-service-bundle"
declare -r DST_DIR="$HOME/Library/Caches/yandex/swift-build/s3/$TOOL_NAME/$RELEASE_VERSION"

declare SWBBuildServiceBundle="$DST_DIR/SWBBuildServiceBundle"

if [ -x "$SWBBuildServiceBundle" ]; then
    export SWBBuildServiceBundle
    return
fi

mkdir -p "$DST_DIR"

declare -r ARCHIVE_NAME="SWBBuildServiceBundle-$CUSTOM_SWIFT_BUILD_VERSION-macos-$ARCH.tar.gz"
declare -r ARCHIVE_PATH="$DST_DIR/$ARCHIVE_NAME"

if [ -f "$ARCHIVE_PATH" ]; then
    rm -rf "$ARCHIVE_PATH"
fi

declare -r BASE_URL="<YOUR_S3_BASE_URL>"
declare -r BUCKET_PATH="tools/swift-build"
declare -r URL="${BASE_URL%/}/${BUCKET_PATH%/}/$TOOL_NAME-$RELEASE_VERSION"

if ! curl -fsSL -o "$ARCHIVE_PATH" "$URL"; then
    echo "Downloading $URL failed!" >&2
    exit 1
fi

if ! tar -xzf "$ARCHIVE_PATH" -C "$DST_DIR"; then
    echo "Unarchiving $ARCHIVE_PATH to $DST_DIR failed!" >&2
    exit 1
fi

rm -rf "$ARCHIVE_PATH"

if [ ! -x "$SWBBuildServiceBundle" ]; then
    echo "$SWBBuildServiceBundle is not executable!" >&2
    exit 1
fi

export SWBBuildServiceBundle

Чтобы разработчикам не приходилось вручную проставлять XCBBUILDSERVICE_PATH, мы задействовали Shadowenv. Это утилита, которая позволяет менять переменные окружения при входе в определённые директории.

Плюс этого подхода в его изолированности: магия работает только внутри папки проекта, где настроена директория .shadowenv.d. Как только вы выходите из неё, все переменные среды (включая путь к нашему кастомному xcodebuild) возвращаются в исходное состояние. Это гарантирует, что наши патчи не сломают сборку стандартных проектов на той же машине.

При внедрении прокси‑скриптов мы столкнулись с несколькими неочевидными проблемами:

  1. Бесконечная рекурсия. Некоторые системные вызовы (например, через xcode‑select) пытались вызвать xcodebuild, попадали на наш скрипт, который снова пытался вызвать xcodebuild... Чтобы разорвать этот цикл, в критических местах пришлось явно прописывать абсолютный путь к системному /usr/bin/xcodebuild.

  2. Fastlane. Популярный инструмент scan по умолчанию жёстко завязан на системные пути. Мы использовали опцию xcodebuild_command, переопределив её следующим образом: env NSUnbufferedIO=YES #{xcodebuild_wrapper_dir}/xcodebuild (где xcodebuild_wrapper_dir — путь к нашей директории с кастомным билдом).

  3. Сторонние плагины (SweetPad). Некоторые инструменты для VS Code и вовсе не поддерживали кастомизацию команды сборки. Но, поскольку это Open Source, мы просто отправили туда PR с добавлением возможности переопределить путь к xcodebuild.

В итоге мы получили среду, в которой кастомная система сборки подхватывается автоматически.

Но есть одно важное ограничение: поскольку вся магия подмены завязана на переменные окружения и shadowenv, Xcode необходимо запускать исключительно через терминал (командами open или xed).

Эпилог. Жизнь после фикса

Разработчики просто открывают проект, как обычно, даже не подозревая, что за кулисами работает прокачанный сервис сборки.

Эта история доказала: когда стандартные инструменты Apple пасуют перед масштабами вашего проекта, Open Source даёт вам легальный способ не ждать милости от релиза, а починить всё самостоятельно. В итоге один патч сэкономил тысячи часов.


Всё началось с загадочного падения Xcode 26.2 на огромном проекте, а закончилась официальным признанием нашего решения в репозитории Apple. Подводя черту под этим исследованием, мы можем выделить три главных результата:

  1. Победа над древним злом: мы локализовали и устранили критическую проблему переполнения стека в swift‑build, которая годами мешала сборке сверхкрупных проектов.

  2. Вклад в Open Source Swift: наш патч был принят в основной репозиторий и станет частью Swift 6.3. Теперь это решение доступно каждому iOS‑разработчику в будущих версиях Xcode.

  3. Гибкая инфраструктура подмены: мы создали масштабируемое решение на базе XCBBUILDSERVICE_PATH. Теперь мы можем оперативно фиксить баги инструментов сборки и раскатывать их на все проекты компании, не дожидаясь официальных релизов от Apple.

Теперь, когда путь «исправление → сборка → прозрачная подмена» обкатан, мы планируем расширить эту практику. Если какой‑то компонент Xcode начнёт вести себя нестабильно на наших масштабах — у нас уже есть готовый механизм, чтобы это исправить.

Мораль проста: не бойтесь заглядывать под капот, даже если под этим капотом скрывается/находится самый мощный двигатель от Apple.

Комментарии (0)