Hive Hive
Sign in

fix(cli): stream xcodebuild test output with NSUnbufferedIO

GitHub issue · Closed

Metadata
Source
tuist/tuist #11046
Updated
Jun 24, 2026
Domains
CLI
Details

Resolves https://github.com/tuist/Command/issues/273

What

tuist test with parallel testing enabled (-parallel-testing-enabled YES, the default for many UI test schemes) produces no output during the entire test-execution phase - everything is flushed at once when xcodebuild exits. For long suites (15-20+ minutes) this trips CI no-output timeouts (e.g. CircleCI kills the job) and hides the live test logs.

Why (root cause)

With parallel testing, xcodebuild checks isatty(stdout) and buffers all of its output until the process exits when stdout is not a terminal. Tuist runs xcodebuild through a pipe (Command’s CommandRunner), so isatty(stdout) is false and the output is withheld until the end. Only the test action is affected; build-for-testing and test-without-building stream fine through a pipe (which is why the documented workaround of splitting into --build-only then --without-building avoids it).

The fix

Set NSUnbufferedIO=YES in the environment of the xcodebuild subprocess. This makes xcodebuild flush its output in real time even when stdout is a pipe. It’s a one-line change in XcodeBuildController.run, applied to all xcodebuild invocations (harmless - build/archive already stream; it just makes writes unbuffered). It is also the approach xcbeautify documents for parallel/concurrent destination testing.

Why not a pseudo-terminal

Attaching stdout to a PTY also works (it makes isatty true), and an earlier revision of this PR did exactly that. NSUnbufferedIO=YES was chosen instead because it is simpler and more robust:

  • No PTY to allocate. PTY allocation can fail - during development this machine hit sustained posix_openpt ENXIO windows. A PTY-based fix has to fall back to a plain pipe when it can’t get a PTY, i.e. it silently stops fixing the bug exactly when allocation is unavailable. An env var always applies.
  • stdout and stderr stay separate. A PTY merges them into one stream and applies terminal line discipline (\n -> \r\n, ANSI).
  • Reuses the existing CommandRunner (and its concurrency limiting) instead of a parallel subprocess implementation.

Validation

Controlled A/B on the same machine and the same fixture - an iOS app plus a unit-test target with 6 classes that each Thread.sleep for 25s, run with -parallel-testing-enabled YES -parallel-testing-worker-count 2 - timestamping when stdout bytes arrive:

plain pipe (today) NSUnbufferedIO=YES
longest silence during the run 97s 25s
when the 6 Test case … passed lines arrive all at t+108s (one burst at exit) spread over t+39s … t+89s

Plain pipe = ~97s of total silence then a burst at exit (the no-output-timeout bug). NSUnbufferedIO=YES = results stream out in waves as classes finish.

XcodeBuildControllerTests assert the xcodebuild argument list, which this change does not touch (it only adds an environment variable), so they are unaffected; CI builds and runs them on this PR.

How to test locally

tuist test <Scheme> -- -parallel-testing-enabled YES -parallel-testing-worker-count 2

Before this change, no output appears during the test-execution phase and everything prints at once when xcodebuild exits. With it, results stream out as test classes finish.

Comments
T
tuist[bot] Jun 4, 2026

🛠️ Tuist Run Report 🛠️

Tests 🧪
Scheme Status Cache hit rate Tests Skipped Ran Commit
TuistUnitTests 43 % 2901 5 2896 9d3a83af1
Flaky Tests ⚠️
  • TuistUnitTests: 2 flaky tests (View all)
Test case Module Suite
parseTestStatuses_returnsPassingModuleNames() TuistXCResultServiceTests XCResultServiceTests
parseTestStatuses_returnsCorrectStatuses() TuistXCResultServiceTests XCResultServiceTests
Builds 🔨
Scheme Status Duration Commit
TuistUnitTests 4m 52s 9d3a83af1
TA
tuist-atlas[bot] Jun 5, 2026

The fix for streaming xcodebuild test output with NSUnbufferedIO is now available in 4.195.17. Update to this version to resolve the no-output-timeout issue when running tests with parallel testing enabled.