Files
obsidian-vault/work/projects/vscode-migration/tech-design-xcode-build-server.md
T

9.2 KiB
Raw Blame History

Tech Design: VS Code and Cursor on the Xcode projects through a build server

Author: Alex M Reviewer: TBD Stakeholders: macOS and iOS teams Project: Migration to fully VSCode-supported builds&debugging · notes: projects/vscode-migration/xcode-build-server

Background & Requirements

  • Developers increasingly work in VS Code and Cursor (AI agents, multiple worktrees), but code intelligence for our apps only exists in Xcode.
  • The Swift extension's language server, sourcekit-lsp, understands SwiftPM packages natively. For other build systems it talks to a build server through the Build Server Protocol (BSP), configured by a buildServer.json in the folder.
  • The current plan makes the macOS browser code a SwiftPM package (projects/vscode-migration/tech-design-spm-browser-package). That is a big change to the project, and macOS only.
  • sourcekit-xcode-bsp (Apache 2.0, early-stage, version 0.1.0) is a build server for .xcodeproj/.xcworkspace. It uses swift-build, the open-source build engine of Xcode, so it reads the projects the way Xcode does.
  • Requirements:
    • Code intelligence (completion, diagnostics, jump to definition, references) for app, test and package code, macOS and iOS.
    • Build, run, test and debug from the editor.
    • No change to the Xcode projects, schemes, CI or release.
    • Works in many worktrees at once without hours of indexing per worktree.

Problem Statement

Out of the box, sourcekit-xcode-bsp 0.1.0 on our workspace:

  • gives app code intelligence after ~3 minutes;
  • gives nothing in test files;
  • produces build failures and drops ~250 object files into the repository root;
  • needs an estimated 2 hours to index a worktree.

Use sourcekit-xcode-bsp with three changes to it, plus a small setup in the repository.

Changes to sourcekit-xcode-bsp (scripts/vscode/sourcekit-xcode-bsp.patch, to be upstreamed)

  1. configuration, default Debug. Without it swift-build uses the projects' default configuration, Release, which has no testability. Every @testable import failed, so test files had no results.
  2. targets include/exclude patterns on <project>/<target>, plus a list-targets command to check them. The server put all 977 workspace targets into one build: DMG and App Store variants, both platforms, and 3–4 variants of each package library. They build into the same paths, so swift-build dropped tasks (failed links, stray .o files). Xcode never builds them together; a scheme picks one variant and package targets come in as dependencies. We configure the same way: 86 targets, which pull in 288.
  3. A per-target cache of Swift compiler arguments. swift-build computes them for every file from scratch (~0.8 s each, one at a time). Within a Swift target they differ only in the file's -index-unit-output-path, which the server already reports. The first request of a target is computed and the rest are answered from the cache. Indexing a fresh worktree went from ~2 hours to ~5.5 minutes on an M4 Max.

Also added: buildSettings overrides.

In the repository (DuckDuckGo.code-workspace, scripts/vscode/)

  • setup-build-server.py [macos|ios] writes buildServer.json on folder open: Debug, one platform, and the target filter. The filter keeps the app project and package test products, and drops App Store variants and the other platform's app and local packages.
  • Tasks call xcodebuild.sh: build a scheme (macOS or iOS Simulator) and run unit tests with -only-testing. Output goes through xcbeautify, so errors show in the Problems panel.
  • Launch configurations debug the app with lldb-dap: launch, launch without building, and attach.
  • Compile results shared between worktrees. xcodebuild.sh uses Xcode's compilation cache with the worktree and build folder mapped to /^wt and /^dd. A clean build in a new worktree drops from 179 s to 90–120 s, with no cache misses. Launch configurations map /^wt back with sourceMap, so breakpoints and sources work.
  • Background indexing. index-in-background.py indexes worktrees headless while no editor uses them, and an editor that opens stops it. background-indexing.sh install runs it every 30 minutes while Xcode, VS Code and Cursor are closed (opt-in).
  • Install. install-build-server.sh builds the patched server from a pinned upstream commit into ~/.local/opt.

Steps

  1. Land the setup and the patch in the repository, behind no flags; Xcode users aren't affected.
  2. Open upstream PRs for the three changes; drop the patch when a release has them.
  3. iOS simulator debugging (install, launch waiting for the debugger, attach), as a follow-up.

Pros

  • No project changes. The Xcode projects, schemes and CI stay the source of truth; nothing to keep in sync.
  • Same build settings as Xcode. swift-build plans the build, so flags, macros, plugins, resources and per-configuration settings match Xcode. Hand-written SwiftPM manifests can drift from them.
  • Both platforms and every target type: apps, extensions, app tests, package tests.
  • Fast enough for many worktrees: ~5.5 min first index, background indexing, shared compile cache for builds.
  • Complements the SwiftPM package work rather than replacing it: modularization still helps build times and testing.

Cons

  • A young third-party tool (0.1.0, "early-stage" by its authors) and our patch on top until upstream merges it.
  • One platform per worktree at a time; switching runs the setup again and reindexes.
  • Files outside the configured targets have no results, for example App Store-only files (none today) or packages only the other platform uses.
  • First index of a worktree is ~5.5 minutes, and preparation is serialized inside swift-build.
  • xcodebuild builds take as long as in Xcode; VS Code adds nothing there except the shared cache for new worktrees.

Risks

  • swift-build changes with Xcode. The server bundles its own swift-build build, pinned by upstream, while the compiler comes from the selected Xcode. Mitigation: pin a tested commit in install-build-server.sh; check after each Xcode update.
  • Upstream doesn't take the patches or the project stalls. Mitigation: keep a fork under the DuckDuckGo organization; the patch is ~350 lines plus tests.
  • Prefix-mapped debug info (/^wt): a debugger without the source map shows no sources. Mitigation: only xcodebuild.sh builds use it, and the launch configurations set sourceMap. Xcode builds are unchanged.
  • Background indexing costs CPU and disk (~5 GB per worktree in .build/). Mitigation: it's opt-in, runs only while editors are closed, at low priority, and skips worktrees that haven't changed.

Notes

  • [1] Measured on an M4 Max with Xcode 27.1 on main at 3d850d74dc.
    • Full index of a fresh worktree: 323–347 s. That is ~1 min to load the workspace and resolve packages, then ~3.5 min to prepare and index, running up to 11 compilers in parallel.
    • Before the arguments cache: 6,000 of ~8,000 index units after 48 min.
  • [2] The compilation cache doesn't speed up the language server, because swift-build doesn't use it for index preparation. A cold and a warm cache gave the same time. So the build server config doesn't set it.
  • [3] Sharing the index between worktrees was rejected. Index records hold absolute paths, and sourcekit-lsp reindexes any file newer than its record, which is every file in a fresh checkout.
  • [4] Alternatives considered:
    • xcode-build-server (parses Xcode build logs): needs a full Xcode build first and goes stale until the next build.
    • SwiftPM package for the app code (current plan): the package work stays useful, but the editor setup doesn't have to wait for it.
    • SweetPad extension: wraps xcodebuild and xcode-build-server; same staleness, plus another extension to maintain.

Testing

  • Code intelligence checked with a headless LSP client (hover, definition, completion, a diagnostic for an injected type error) on:
    • a macOS app file;
    • a macOS unit test, including a jump into app code;
    • a package test;
    • an iOS app file in iOS mode.
  • xcodebuild.sh build "macOS Browser" builds. lldb stops at a breakpoint set by full path in the cache-mapped build, with sources shown.
  • Background indexer:
    • full run;
    • an editor taking over mid-run, with no orphaned processes;
    • selecting only configured worktrees.
  • sourcekit-xcode-bsp: 50 unit tests pass, including new tests for the target filter and the arguments cache (single flight, invalidation, stale results).
  • To do before rollout: a pass through the VS Code and Cursor UI (tasks, Problems panel, lldb-dap launch and attach) by two people on fresh machines.

Additional Considerations

  • Privacy / Security: No change to the apps. The install script downloads and builds sourcekit-xcode-bsp from its public repository at a pinned commit. Everything runs locally.
  • Site Breakage / Experimentation: Not applicable.
  • Operational: No CI or release change. Opting in means installing the server and opening the workspace file. Xcode users aren't affected.
  • Localization: Not applicable.