Hive Hive
Sign in

tuist install hangs deterministically on low-core macOS CI VMs — child swift-package blocked with zero open sockets (not the xcodebuild case from #9967)

GitHub issue · Open

Metadata
Source
tuist/tuist #12366
Updated
Aug 15, 2026
Domains
CLI Cache
Details

Summary

Cold tuist install on GitHub-hosted macOS runners (3–5 vCPU VMs) hangs forever — deterministically, 7/7 — at the end of the binary-artifact download phase. The hung child swift-package has zero open network sockets (it is not waiting on the network) and the parent is just waiting for it, which points to the child being blocked writing to a pipe the parent stopped draining (tuist/Command#226), starved by the core-count-sized cooperative pool. Never reproduces on 10-core physical machines. Unlike #9967 (closed as an Apple/runner issue), the child here is tuist’s own swift-package spawn, not xcodebuild.

What happened

On GitHub Actions hosted macOS runners, a cold tuist install hangs forever (>30 min observed, no timeout), always at the same point: right after the last “Downloading binary artifact” log line. We reproduced it 7 out of 7 times across two runner tiers. The same project, same tuist and Xcode versions, has never hung on 10-core physical machines (M2 Pro Mac minis and developer laptops).

Environment

  • Runners: GitHub-hosted macos-26 (3 vCPU, virtualized M1, 7 GB RAM) and macos-26-xlarge (5 vCPU, virtualized M2 Pro, 14 GB RAM). macOS 26 image.
  • Xcode: 26.4.1 and 26.6 (both reproduce).
  • tuist: 4.182.0 installed via mise (aqua backend). Also reproduces on 4.204.0 whenever it takes the SwiftPM-subprocess path (i.e. when the committed Package.resolved is out of date and SwifterPM falls back to resolveWithSwiftPackageManagerProcess).
  • Project: large modular iOS app, Package.swift at the repo root, ~45 SPM dependencies including 18 binaryTargets (Firebase/grpc/GoogleMaps zips from dl.google.com; AppsFlyer/Intercom/SwiftLint as GitHub release assets).
  • Cold caches: fresh VM, empty ~/Library/Caches/org.swift.swiftpm, empty .build.

How to reproduce

  1. GitHub Actions job on macos-26 (the 3-core tier makes it most reliable, but 5-core reproduces too).
  2. Checkout a project with a similar dependency graph (many concurrent binary-artifact downloads seems to be the key ingredient).
  3. Run tuist install cold.
  4. Watch it download fast (~2.5 GB/min) and then freeze at the end of the binary-artifact phase, indefinitely.

Evidence collected while hung

We attached lsof and sample to the hung process tree (parent tuist, child swift-package … resolve):

  • lsof -i on both processes: zero open network sockets. It is not waiting on the network.
  • sample of the parent: main thread waiting; NIOThreadPool threads parked in ConditionLock.lock(whenValue:)pthread_cond_wait — i.e. a normal idle pool, the parent is simply waiting for the child to exit.
  • Child command line (4.204.0 case): swift-package --package-path <root> --cache-path ~/.cache/swifterpm --scratch-path <root>/.build --disable-scm-to-registry-transformation resolve — alive, ~0 CPU, no sockets, no file-system progress.
  • Network explicitly ruled out: from the same VM, the largest artifact (grpc.zip, 103 MB) downloads in 2–4 s via curl -4, curl -6, and a minimal URLSession probe. Disabling IPv6 system-wide (networksetup -setv6off) changes nothing.

Why we think this is tuist and not Apple/GitHub (re #9967)

#9967 described the same symptom but the child there was xcodebuild -resolvePackageDependencies (Xcode’s default integration), and it was closed as an Apple/runner issue, with a maintainer note asking for a repro where the evidence points elsewhere. In our case:

  • The integration is XcodeProj-based and the hung child is swift-package spawned by tuist, not xcodebuild.
  • A child with no sockets that stops making progress while its parent waits is consistent with the child being blocked writing to a full stdout/stderr pipe that the parent stopped draining — exactly the failure mode described in tuist/Command#226. The pipe readers in Command’s CommandRunner are unstructured Task {}s scheduled on the Swift cooperative pool, whose width equals the core count — which would explain why 3–5 vCPU VMs starve the readers deterministically and 10-core machines never do.
  • The core-count correlation is absolute in our data: 7/7 hangs at 3–5 vCPU, 0/N at 10 cores, same versions.

Workarounds we validated

  • Pre-seeding the SPM caches (an actions/cache mirror of ~/Library/Caches/org.swift.swiftpm populated on a machine that doesn’t hang) avoids the download phase entirely → no hang.
  • On ≥4.203 with an up-to-date committed Package.resolved, SwifterPM skips the subprocess → no hang (but we then hit a separate SwifterPM restore bug, filed as #12367).

Happy to provide the full sample outputs, lsof dumps, and per-minute progress logs from our CI runs if useful.

Flights

Investigate, reproduce, or fix this item in an isolated repository. Each Flight preserves its outcome and agent session.

New Flights are paused Configure model inference, GitHub, and a sandbox provider to start another Flight. Existing results remain available below.
No Flights yet

Start a Flight and preserve its objective, outcome, and session here.

Comments
D
dosubot[bot] Aug 15, 2026

Hi @danieldalto! I’m Dosu and I’m helping the Tuist team.

Your diagnosis is thorough and lines up with several real issues in the Command package. Here’s what the code and recent fixes reveal:

Fixes already shipped (and whether they cover your case)

The pipe-deadlock described in Command#226 motivated Command#249, which replaced the blocking waitUntilExit() with an async terminationHandler + CheckedContinuation. This shipped in tuist as Command 0.14.4 via PR #10682. It fixes the fan-out case (many concurrent swift package dump-package subprocesses starving the pool), but your case is different: a single swift-package … resolve subprocess producing large output. The stdoutTask and stderrTask inside CommandRunner.run are still unstructured Task {}s on the cooperative pool [1] — if swift-package is chatty enough (18 binary-artifact downloads logging progress), it can fill the pipe buffer faster than those reader tasks get scheduled on a 3-vCPU pool. That matches your observation exactly.

A follow-up fix — Command 0.14.9 / PR #11158 — added an AsyncResourceLimiter to bound concurrent subprocess launches by fd availability [2]. This prevents fd exhaustion but doesn’t change how the pipe-reader tasks are scheduled.

The most directly relevant fix for your symptom

PR #12069 — “bound and retry SwifterPM binary-artifact downloads” — merged July 25, adds per-request idle (60 s) and total transfer (600 s) timeouts plus retry logic to SwifterPM’s HTTP client. A stalled connection now surfaces as a bounded error rather than hanging open-ended. If your versions (4.182.0 / 4.204.0) predate this fix, upgrading is the first thing to try.

If #12069 is already in your version

Then your hang is more likely the pipe-buffer deadlock variant. Two things to confirm:

  1. Does the child swift-package produce large stdout/stderr output? Redirecting to a file instead of a pipe would confirm the pipe-full hypothesis.
  2. The AsyncResourceLimiter derives its cap from RLIMIT_NOFILE [3] — on a 3-vCPU VM with a soft limit of 256, the resulting cap is low, which could interact with how the pipe readers are scheduled.

The remaining gap is that the pipe-reader tasks are unstructured Task {}s competing on the cooperative pool. For a single subprocess with voluminous output on a very small pool, they can still be starved even after #249. Making these detached tasks or pinning them to a dedicated thread would close this case.

For a deeper discussion and to share those sample outputs and progress logs, I’d suggest posting on the community forum under Troubleshooting — that data will help the maintainers confirm whether this is pipe-buffer exhaustion or the SwifterPM timeout scenario from #12069.

To reply, just mention @dosu.


Docs are dead. Just use Dosu.

Leave Feedback Ask Dosu about tuist Share Dosu with your team