Skip to content

Add opt-in memory shims for attachment payloads and test output - #3051

Open
erneestoc wants to merge 2 commits into
bazelbuild:mainfrom
erneestoc:test-memory-shims
Open

Add opt-in memory shims for attachment payloads and test output#3051
erneestoc wants to merge 2 commits into
bazelbuild:mainfrom
erneestoc:test-memory-shims

Conversation

@erneestoc

@erneestoc erneestoc commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Summary

xcodebuild test-without-building buffers two classes of test payload in the client process's memory, and no xcodebuild flag avoids either — measured against -resultBundlePath, -resultStreamPath (requires a bundle path; changes nothing), -collect-test-diagnostics never, and every attachment-lifetime setting on Xcode 26.2:

  1. XCTAttachment payloads transit xcodebuild at ~1.4× their size — even under attachment_lifetime = "keepNever", which discards them only at finalization, after the full transfer (final bundle 208K from a 936 MiB run).
  2. The test process's console output is captured into xcodebuild's structured session log at ~26× per byte, independent of line length.

For attachment- or log-heavy suites this dominates harness memory and is what limits how many simulators a machine can run in parallel.

This PR adds three opt-in features, following the apple.swizzle_absolute_xcttestsourcelocation precedent — the feature force-loads a small shim into the test bundle and sets its activating variable in the simulator test environment:

  • --features=apple.test_drop_attachment_payloads — payloads >4KB replaced with a short note before reaching XCTest (for keepNever suites, whose payloads were doomed after transfer anyway)
  • --features=apple.test_spill_attachment_payloads — payloads written to $TEST_UNDECLARED_OUTPUTS_DIR/spilled_attachments/ (delivered in Bazel's outputs zip), attachment carries a note naming the file
  • --features=apple.test_redirect_stdout — test process stdout dup2()d to $TEST_UNDECLARED_OUTPUTS_DIR/test_process_output.log. stdout only: XCTest reports results on stderr, and the runner's tests-ran detection depends on those lines (verified both ways)

Features disabled = byte-for-byte today's behavior (linked-but-inactive measured identical to control).

Measurements

Hosted ios_unit_test, iPhone 16 / iOS 26.2 simulator, Xcode 26.2, RSS sampled at 2Hz across the process family; "baseline" is the identical test with no attachments/output:

Scenario xcodebuild peak
Baseline (trivial hosted test) 231 MiB
5×100MB attachments, stock (any lifetime, bundle or not) 936 MiB
Same, shims linked but env unset 935 MiB (= control)
Same, RULES_APPLE_ATTACHMENT_PAYLOADS=drop 233 MiB
Same, =spill (payloads delivered in outputs.zip) 232 MiB
300MB stdout, stock 7,910 MiB
Same, apple.test_redirect_stdout 232 MiB (all 300MB delivered byte-exact in outputs.zip)

Wall-time, 3 trials each (bazel-reported test time):

t1 t2 t3 mean
300MB logs, stock 27.8s 26.9s 27.2s 27.3s
300MB logs, apple.test_redirect_stdout 2.9s 2.3s 2.3s 2.5s
trivial test, stock 2.6s 2.3s 2.2s 2.4s
trivial test, redirect feature on 2.4s 2.3s 4.3s 3.0s

The redirected log-heavy test runs as fast as an empty test — xcodebuild's log-capture pipeline processes ~11MB/s, so heavy output also serializes the run; a local file write does not. Trivial tests are unaffected in both dimensions.

Design notes

  • The plumbing mirrors apple.swizzle_absolute_xcttestsourcelocation exactly: private attrs on the test bundle rule, feature-gated -force_load in apple_test_bundle_support, and the env pair from _get_test_memory_shim_environment alongside the Main Thread Checker precedent in apple_test_rule_support.
  • attachment_payload_shim intercepts XCTAttachment's public designated initializer (initWithUniformTypeIdentifier:name:payload:userInfo:, unchanged since Xcode 9) via method_setImplementation, so it covers data-based attachments from any library (e.g. swift-snapshot-testing's failure diffs, which are PNG Data). drop is intended for keepNever suites — those payloads were going to be discarded after transfer anyway; spill keeps the bytes, in Bazel's test outputs instead of the result bundle.
  • test_output_redirect_shim is pure POSIX (dup2 at load time), no swizzling. stdout is the supported mode with this runner: XCTest emits its result lines on stderr, and redirecting stderr breaks the runner's tests-ran detection — verified in both directions and documented in the source.
  • Both fail open: missing class/selector or unset env leaves XCTest untouched, with install/degradation logged.
  • Runner-suite canary tests cover both shims end-to-end (test_ios_unit_test_attachment_payload_shim_drops, test_ios_unit_test_output_redirect_shim_stdout), so an Xcode release that restructures either seam turns a named test red instead of silently restoring the memory cost.

Steps to reproduce the measurements

  1. Create a hosted unit test (any ios_unit_test with a test_host) with a method adding 5×100MB XCTAttachment(data:) and a method writing 300MB to stdout.
  2. Sample memory during the run: while :; do ps -axo rss=,comm= | grep -E "xcodebuild|testmanagerd"; sleep 0.5; done
  3. Run each variant with --nocache_test_results, toggling --features=apple.test_drop_attachment_payloads / apple.test_spill_attachment_payloads / apple.test_redirect_stdout, and compare xcodebuild's peak RSS and the reported test time.

Test plan

  • Full //test:ios_xctestrun_runner_unit_test suite passes locally with the two new canaries active (45/45); canaries exercise the features end-to-end (feature-gated linking + environment), with no explicit shim dep or env on the canary targets.
  • Linked-but-inactive measured identical to control (935 vs 936 MiB) — default-off is safe.
  • spill files and the redirected log verified present in outputs.zip (byte-exact 314,572,830 for the 300MB run).
  • buildifier clean.

@adincebic adincebic left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you prefix those feature flag names with 'test' or something along those lines?

`xcodebuild test-without-building` buffers two classes of test payload in
the client's memory, and no xcodebuild flag avoids either (measured
against -resultBundlePath, -resultStreamPath, -collect-test-diagnostics,
and attachment lifetimes on Xcode 26.2):

- XCTAttachment payloads transit xcodebuild at ~1.4x their size - even
  under an attachment lifetime of "keepNever", which discards them only
  at finalization, after the full transfer. Measured: a hosted test
  adding 500MB of attachments drives xcodebuild from its 231 MiB
  baseline to 936 MiB.
- The test process's console output is captured into xcodebuild's
  structured session log at ~26x per byte, independent of line length.
  Measured: 300MB of stdout drives xcodebuild to 7.9 GiB and the test
  to 27.3s mean.

For attachment- or log-heavy suites this dominates harness memory and
limits how many simulators a machine can run in parallel.

Three opt-in features, following the apple.swizzle_absolute_
xcttestsourcelocation precedent (feature-gated force_load into the test
bundle, plus the activating variable in the simulator test environment):

- apple.test_drop_attachment_payloads: payloads over 4KB are replaced
  with a short note before reaching XCTest. For keepNever suites, where
  the payloads were going to be discarded after transfer anyway.
  Measured: 936 -> 232 MiB.
- apple.test_spill_attachment_payloads: payloads are written to
  $TEST_UNDECLARED_OUTPUTS_DIR/spilled_attachments/ (delivered in
  Bazel's test outputs zip) and the attachment carries a note naming
  the file. Measured: 936 -> 232 MiB.
- apple.test_redirect_stdout: the test process's standard output is
  dup2()d at load time to $TEST_UNDECLARED_OUTPUTS_DIR/
  test_process_output.log. Only stdout: XCTest reports results on
  stderr, and the runner's tests-ran detection depends on those lines
  (verified both ways). Measured: 7.9 GiB -> 232 MiB, and the
  300MB-stdout test drops from 27.3s mean to 2.5s mean over 3 trials -
  identical to an empty test - while trivial tests are unaffected.

The attachment shim intercepts XCTAttachment's public designated
initializer (unchanged since Xcode 9); both shims fail open with the
install or degradation logged (linked-but-inactive measured identical to
control, 935 vs 936 MiB). Runner-suite canary tests exercise both
features end-to-end - linking and environment - so an Xcode release
that restructures either seam turns a named test red instead of
silently restoring the memory cost.
@erneestoc

Copy link
Copy Markdown
Contributor Author

Done — renamed with a consistent test prefix: apple.test_drop_attachment_payloads, apple.test_spill_attachment_payloads, apple.test_redirect_stdout. Docs, canary tests, and PR description updated; canaries re-run green against the new names.

@erneestoc
erneestoc requested a review from adincebic August 21, 2026 06:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants