Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .bazelrc
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,13 @@ build:clang --host_cxxopt="-fvisibility=hidden"

# iOS setup
build:ios --ios_multi_cpus=sim_arm64
build:ios --@rules_apple//apple/build_settings:ios_simulator_device="iPhone 16 Pro"
# The simulator device is not pinned here: which device profiles exist depends on
# the installed Xcode's iOS runtime, so any value checked in goes stale. Set it in
# .bazelrc.user, e.g.
#
# build:ios --@rules_apple//apple/build_settings:ios_simulator_device="iPhone 17"
#
# `xcrun simctl list devicetypes` lists what is available locally.

# Platform-specific options for each supported platform.
build:remote_linux_x64 --extra_execution_platforms=//platform/linux_x64
Expand Down
23 changes: 23 additions & 0 deletions MODULE.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,29 @@ use_repo(maven, "maven")
bazel_dep(name = "rules_kotlin", version = "2.3.20")
bazel_dep(name = "rules_perl", version = "1.1.1")
bazel_dep(name = "rules_swift", version = "4.0.0-rc2")

# Swift Package Manager deps, used by //tools/simulator_manager.
bazel_dep(name = "rules_swift_package_manager", version = "1.22.0")

swift_deps = use_extension(
"@rules_swift_package_manager//:extensions.bzl",
"swift_deps",
)
swift_deps.from_package(
declare_swift_deps_info = True,
resolved = "//tools/simulator_manager:Package.resolved",
swift = "//tools/simulator_manager:Package.swift",
)
use_repo(
swift_deps,
"swift_deps_info",
"swift_package",
"swiftpkg_shellout",
"swiftpkg_swift_argument_parser",
"swiftpkg_swift_nio",
"swiftpkg_swift_nio_extras",
)

bazel_dep(name = "rules_nodejs", version = "6.7.4")
bazel_dep(name = "aspect_rules_ts", version = "3.8.10")
bazel_dep(name = "aspect_bazel_lib", version = "2.22.5")
Expand Down
900 changes: 766 additions & 134 deletions MODULE.bazel.lock

Large diffs are not rendered by default.

124 changes: 123 additions & 1 deletion ios/BUILD
Original file line number Diff line number Diff line change
@@ -1,7 +1,34 @@
load("@bazel_skylib//rules:build_test.bzl", "build_test")
load("@rules_apple//apple:ios.bzl", "ios_application", "ios_ui_test", "ios_unit_test")
load(
"@rules_apple//apple/testing/default_runner:ios_xctestrun_runner.bzl",
"ios_xctestrun_runner",
)
load("@rules_swift//swift:swift.bzl", "swift_library")

# Leases each test its own simulator from //tools/simulator_manager, so
# concurrent actions on one worker never share a device.
ios_xctestrun_runner(
name = "ios_leased_simulator_runner",
clean_up_simulator_action = "//tools/simulator_manager:release_simulator",
create_simulator_action = "//tools/simulator_manager:lease_simulator",
random = False,
# reuse_simulator doubles as the exclusivity toggle for the lease: opting out
# of reuse means no other test shares this device.
reuse_simulator = False,
)

# Non-exclusive leases: concurrent tests may share one simulator. Sharing is
# opportunistic, not bounded — a request only joins a device that is already
# active, so tests dispatched simultaneously can still each get their own.
ios_xctestrun_runner(
name = "ios_shared_simulator_runner",
clean_up_simulator_action = "//tools/simulator_manager:release_simulator",
create_simulator_action = "//tools/simulator_manager:lease_simulator",
random = False,
reuse_simulator = True,
)

swift_library(
name = "HelloAppLibrary",
srcs = ["HelloApp.swift"],
Expand Down Expand Up @@ -49,7 +76,102 @@ swift_library(
ios_ui_test(
name = "HelloAppUITest",
minimum_os_version = "26.0",
runner = "@rules_apple//apple/testing/default_runner:ios_xctestrun_ordered_runner",
runner = ":ios_leased_simulator_runner",
test_host = ":HelloApp",
deps = [":HelloAppUITestLib"],
exec_properties = {
"sandboxAllowed": "False",
},
)

swift_library(
name = "HelloConcurrentTestLib",
testonly = True,
srcs = ["HelloConcurrentTest.swift"],
module_name = "HelloConcurrentTest",
)

# Exercises concurrent leasing: run with --runs_per_test to get several
# simultaneous leases from one target. Unlike HelloAppUITest this has no test
# host, so it takes rules_apple's `simctl spawn` path rather than `xcodebuild`.
#
# bazel test --config=ios --runs_per_test=3 //ios:HelloConcurrentTest
#
# Each run's log reports its simulator UDID; no two concurrent runs should match.
# Manual because it is sensitive to how much memory the host has — see the
# simulator_manager README.
ios_unit_test(
name = "HelloConcurrentTest",
minimum_os_version = "26.0",
runner = ":ios_leased_simulator_runner",
tags = ["manual"],
timeout = "long",
deps = [":HelloConcurrentTestLib"],
)

# Same source, but leasing non-exclusively: several concurrent runs should
# collapse onto fewer devices. Measured on a 16 GiB worker, 8 runs shared 2
# simulators.
ios_unit_test(
name = "HelloSharedSimulatorTest",
minimum_os_version = "26.0",
runner = ":ios_shared_simulator_runner",
tags = ["manual"],
timeout = "long",
deps = [":HelloConcurrentTestLib"],
)

swift_library(
name = "HelloDyingHostTestLib",
testonly = True,
srcs = ["HelloDyingHostTest.swift"],
module_name = "HelloDyingHostTest",
)

# Kills its own test host to check whether that alone produces the
# "doesn't have a simulator leased" warning. Hosted, so the lease is held by the
# rules_apple runner script rather than the test host -- see the test source.
#
# bazel test --config=ios --config=opal --config=remote_macos_arm64 \
# --test_env=SIMULATOR_MANAGER_DEATH_MODE=signal //ios:HelloDyingHostTest
ios_unit_test(
name = "HelloDyingHostTest",
minimum_os_version = "26.0",
runner = ":ios_leased_simulator_runner",
tags = ["manual"],
test_host = ":HelloApp",
deps = [":HelloDyingHostTestLib"],
exec_properties = {
"sandboxAllowed": "False",
},
)

swift_library(
name = "HelloHostedSnapshotTestLib",
testonly = True,
srcs = ["HelloHostedSnapshotTest.swift"],
module_name = "HelloHostedSnapshotTest",
)

# Hosted so rules_apple drives it with `xcodebuild -destination id=`, and leasing
# exclusively so each concurrent action gets its own device.
# Run with a high --runs_per_test to put more concurrent actions on a worker than it has
# simulators:
#
# bazel test --config=ios --config=opal --config=remote_macos_arm64 \
# --runs_per_test=8 //ios:HelloHostedSnapshotTest
#
# Manual for the same reason as HelloConcurrentTest: sensitive to worker memory.
ios_unit_test(
name = "HelloHostedSnapshotTest",
minimum_os_version = "26.0",
runner = ":ios_leased_simulator_runner",
tags = ["manual"],
test_host = ":HelloApp",
timeout = "long",
deps = [":HelloHostedSnapshotTestLib"],
exec_properties = {
"sandboxAllowed": "False",
},
)

29 changes: 29 additions & 0 deletions ios/HelloConcurrentTest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import XCTest

/// Checks that concurrent tests each lease their own simulator.
///
/// Unlike HelloAppConcurrentUITest this has no test host, so rules_apple runs it
/// via `simctl spawn` rather than `xcodebuild`.
///
/// Several targets share this source; each logs its UDID, and no two should
/// match. The sleep holds the lease long enough for the runs to actually overlap.
///
/// `SIMULATOR_MANAGER_HOLD_SECS` lengthens that hold, for experiments that need a
/// lease to still be live while something else happens on the worker -- a daemon
/// upgrade, say. Keep it under the target's timeout.
class HelloConcurrentTest: XCTestCase {
func testHoldsItsOwnSimulator() {
let udid = ProcessInfo.processInfo.environment["SIMULATOR_UDID"] ?? "unknown"
print("CONCURRENT_UNIT_TEST_SIMULATOR_UDID=\(udid)")
XCTAssertEqual(1 + 1, 2)

let hold = ProcessInfo.processInfo.environment["SIMULATOR_MANAGER_HOLD_SECS"]
.flatMap(Double.init) ?? 20
// Bracketed by prints so a log shows whether the lease was still held when
// whatever else was under test happened.
print("CONCURRENT_UNIT_TEST_HOLDING_FOR=\(hold)")
fflush(stdout)
Thread.sleep(forTimeInterval: hold)
print("CONCURRENT_UNIT_TEST_HOLD_DONE")
}
}
78 changes: 78 additions & 0 deletions ios/HelloDyingHostTest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import Darwin
import XCTest

/// Kills its own test host mid-suite, to find out whether a dying test process is
/// enough to produce a "PID N doesn't have a simulator leased" warning.
///
/// The warning means the daemon had no lease for the PID that tried to release
/// one, which happens when the daemon already reclaimed the lease via its
/// leaser-exit listener. The open question is *whose* death triggers that: the
/// lease is keyed on the rules_apple runner script's PID
/// (`XCTESTRUN_RUNNER_PID`), and that script outlives the test host, so killing
/// the host may leave the lease untouched.
///
/// `SIMULATOR_MANAGER_DEATH_MODE` selects how to die.
///
/// * `signal` -- SIGKILL, matching "Test crashed with signal kill before
/// establishing connection".
/// * `exit` -- a zero exit, matching "The test runner exited with code 0 before
/// establishing connection".
/// * `memory` -- allocate and touch pages until the kernel's memory-pressure
/// killer reaps the process, standing in for a worker running several
/// simulators at once.
/// * `hang` -- sleep past the action's timeout, so the test is killed from
/// outside rather than dying on its own.
///
/// Unset means don't die, so the target is also a control.
class HelloDyingHostTest: XCTestCase {
/// Named to sort before `testDies`: XCTest runs cases alphabetically, so this
/// establishes that the suite was really running before the host went away.
func testAaaRunsBeforeDying() {
let udid = ProcessInfo.processInfo.environment["SIMULATOR_UDID"] ?? "unknown"
print("DYING_HOST_SIMULATOR_UDID=\(udid)")
print("DYING_HOST_PID=\(getpid())")
}

func testDies() {
let mode = ProcessInfo.processInfo.environment["SIMULATOR_MANAGER_DEATH_MODE"] ?? ""
print("DYING_HOST_DEATH_MODE=\(mode)")

switch mode {
case "signal":
kill(getpid(), SIGKILL)
case "exit":
exit(0)
case "memory":
exhaustMemory()
case "hang":
// Longer than any timeout this target is given, so the kill comes from
// the harness rather than from here.
sleep(3600)
default:
break
}
}

/// Allocates in chunks and writes to every page, so the pages are resident
/// rather than merely reserved -- a lazily-mapped allocation does not trigger
/// the memory-pressure killer.
///
/// Prints progress so the log records how far it got: if the process is
/// reaped, the last line is the high-water mark.
private func exhaustMemory() {
let chunkBytes = 64 * 1024 * 1024
var chunks: [UnsafeMutableRawPointer] = []

while true {
guard let chunk = malloc(chunkBytes) else {
print("DYING_HOST_MALLOC_FAILED_AT_MB=\(chunks.count * 64)")
fflush(stdout)
return
}
memset(chunk, 1, chunkBytes)
chunks.append(chunk)
print("DYING_HOST_RESIDENT_MB=\(chunks.count * 64)")
fflush(stdout)
}
}
}
56 changes: 56 additions & 0 deletions ios/HelloHostedSnapshotTest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import UIKit
import XCTest

/// The distinguishing features, versus HelloConcurrentTest:
///
/// * it has a test host, so rules_apple drives it with `xcodebuild` and a
/// `-destination id=` rather than `simctl spawn`. `xcodebuild` installs the
/// host onto the device, which is the step that reports `No matching device`
/// when a device is removed underneath it.
/// * it renders views, so it needs a booted UI stack rather than just a process
/// to run in. A test host that dies mid-render produces the "Restarting after
/// unexpected exit, crash, or test timeout" line.
///
/// Many small cases rather than one long sleep: the failures this is meant to
/// provoke land between cases -- on install, or on a device pulled out from under
/// a suite that is partway through -- so the suite needs many opportunities to be
/// interrupted.
class HelloHostedSnapshotTest: XCTestCase {
func testLogsItsDevice() {
let udid = ProcessInfo.processInfo.environment["SIMULATOR_UDID"] ?? "unknown"
print("HOSTED_SNAPSHOT_SIMULATOR_UDID=\(udid)")
}

/// Renders a view hierarchy repeatedly, approximating snapshot work: enough
/// UIKit and memory traffic per case to make contention on a shared device
/// observable, without asserting on images we would then have to record.
func testRendersRepeatedly() {
for iteration in 0 ..< 40 {
let view = UIView(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
view.backgroundColor = iteration.isMultiple(of: 2) ? .systemBlue : .systemRed

let label = UILabel(frame: view.bounds.insetBy(dx: 20, dy: 20))
label.text = "iteration \(iteration)"
label.numberOfLines = 0
view.addSubview(label)

let renderer = UIGraphicsImageRenderer(bounds: view.bounds)
let image = renderer.image { context in
view.layer.render(in: context.cgContext)
}
XCTAssertGreaterThan(image.size.width, 0)
}
}

func testPresentsViewControllers() {
for _ in 0 ..< 10 {
let controller = UIViewController()
controller.view.backgroundColor = .white
let child = UIViewController()
controller.addChild(child)
controller.view.addSubview(child.view)
child.didMove(toParent: controller)
XCTAssertNotNil(controller.view)
}
}
}
Loading
Loading