Harden iOS-sim CI: destination fallback + Noise reconnect test determinism (#1483)

Two CI-robustness fixes for the iOS-simulator job seen on main:

1. The destination picker no longer dies on runner images with placeholder/unavailable simulator lists (macos-26-arm64 20260720.0258): it filters error/unavailable rows, falls back to xcrun simctl's available-device list, and as a last resort creates a simulator from the newest installed iOS runtime's own supported iPhone device types. Each path logs which route was taken.

2. Deflakes NoiseEncryptionServiceTests' quarantine-restore test: the injected 20ms responder timeout also armed during the SETUP handshake, so a loaded runner tore down the half-open responder session before the scenario began (production quarantine/restore verified correct — the failure signature was a fresh msg2 to a session-less peer). Timeout raised to 1s and the fixed 100ms sleep replaced with a bounded waitUntil on the restore condition. No assertion weakened; 10/10 sequential + 3/3 under full-core CPU load.
This commit is contained in:
jack
2026-07-26 16:13:04 +02:00
committed by GitHub
parent a1711bd399
commit 934b2cd2d3
2 changed files with 69 additions and 9 deletions
+49 -5
View File
@@ -190,12 +190,19 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v5
# Some runner images list only placeholder destinations (rows carrying
# "error:" or "unavailable") or ship the selected Xcode without a
# matching iOS simulator runtime, so this walks three paths in order:
# a usable xcodebuild destination, an existing simctl device, and
# finally creating a device from the newest installed iOS runtime.
- name: Select available iPhone simulator - name: Select available iPhone simulator
id: destination id: destination
run: | run: |
destinations=$(xcodebuild -project bitchat.xcodeproj -scheme "bitchat (iOS)" -showdestinations) set -uo pipefail
destinations=$(xcodebuild -project bitchat.xcodeproj -scheme "bitchat (iOS)" -showdestinations 2>/dev/null || true)
destination_id=$(awk -F'id:' ' destination_id=$(awk -F'id:' '
/platform:iOS Simulator/ && /name:iPhone/ && !found { /platform:iOS Simulator/ && /name:iPhone/ \
&& !/error/ && !/unavailable/ && !found {
value=$2 value=$2
sub(/,.*/, "", value) sub(/,.*/, "", value)
gsub(/^[[:space:]]+|[[:space:]]+$/, "", value) gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
@@ -203,10 +210,47 @@ jobs:
found=1 found=1
} }
' <<< "$destinations") ' <<< "$destinations")
if [ -z "$destination_id" ]; then
echo "::error::No available iPhone simulator destination found" if [ -n "$destination_id" ]; then
exit 1 echo "Selected destination via xcodebuild -showdestinations: $destination_id"
else
echo "No usable iPhone destination in -showdestinations output; falling back to simctl"
destination_id=$(xcrun simctl list devices available --json | jq -r '
[.devices | to_entries[]
| select(.key | contains("iOS"))
| .value[]
| select(.isAvailable and (.name | startswith("iPhone")))]
| first.udid // empty')
if [ -n "$destination_id" ]; then
echo "Selected existing simctl device: $destination_id"
else
echo "No available iPhone simulator device; creating one"
# Newest installed iOS runtime plus an iPhone device type that
# runtime itself reports as supported, so the pair always match.
create_spec=$(xcrun simctl list runtimes --json | jq -r '
[.runtimes[] | select(.platform == "iOS" and .isAvailable)]
| sort_by(.version | split(".") | map(tonumber))
| last // empty
| .identifier as $runtime
| ([(.supportedDeviceTypes // [])[]
| select(.productFamily == "iPhone"
or (.name // "" | startswith("iPhone")))]
| first.identifier // empty) as $devicetype
| "\($devicetype) \($runtime)"')
read -r devicetype runtime <<< "$create_spec" || true
if [ -z "${devicetype:-}" ] || [ -z "${runtime:-}" ]; then
echo "::error::No iPhone simulator destination found and none creatable (no installed iOS runtime with an iPhone device type)"
exit 1
fi
destination_id=$(xcrun simctl create ci-iphone "$devicetype" "$runtime") || {
echo "::error::simctl create failed for $devicetype on $runtime"
exit 1
}
echo "Created simulator $destination_id ($devicetype, $runtime)"
fi
fi fi
echo "Using iPhone simulator destination id: $destination_id"
echo "id=$destination_id" >> "$GITHUB_OUTPUT" echo "id=$destination_id" >> "$GITHUB_OUTPUT"
- name: Run iOS tests - name: Run iOS tests
@@ -1235,9 +1235,17 @@ struct NoiseEncryptionServiceTests {
@Test("Lost reconnect completion restores the quarantined transport") @Test("Lost reconnect completion restores the quarantined transport")
func timedOutReconnectRestoresQuarantinedTransport() async throws { func timedOutReconnectRestoresQuarantinedTransport() async throws {
let alice = NoiseEncryptionService(keychain: MockKeychain()) let alice = NoiseEncryptionService(keychain: MockKeychain())
// The injected responder timeout also arms during the ordinary setup
// handshake below (bob is its responder), where the only work between
// message 1 and message 3 is two consecutive synchronous statements.
// It must be generous enough that a preempted runner cannot let the
// timeout fire mid-setup and tear down the half-open responder at
// 20ms a loaded 2-core CI runner did exactly that, so message 3 was
// answered as a fresh initiation (96-byte message 2) and nothing was
// ever quarantined.
let bob = NoiseEncryptionService( let bob = NoiseEncryptionService(
keychain: MockKeychain(), keychain: MockKeychain(),
ordinaryResponderHandshakeTimeout: 0.02 ordinaryResponderHandshakeTimeout: 1.0
) )
let mallory = NoiseEncryptionService(keychain: MockKeychain()) let mallory = NoiseEncryptionService(keychain: MockKeychain())
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
@@ -1253,9 +1261,17 @@ struct NoiseEncryptionServiceTests {
) )
#expect(!bob.hasEstablishedSession(with: alicePeerID)) #expect(!bob.hasEstablishedSession(with: alicePeerID))
try? await Task.sleep(nanoseconds: 100_000_000) // Poll instead of sleeping a fixed interval: the responder timeout
// fires on bob's manager queue at the quarantine deadline, and a
#expect(bob.hasEstablishedSession(with: alicePeerID)) // starved runner can delay that work item well past the deadline.
let restored = await TestHelpers.waitUntil(
{ bob.hasEstablishedSession(with: alicePeerID) },
timeout: TestConstants.longTimeout
)
try #require(
restored,
"Responder timeout should restore the quarantined transport"
)
let oldTransport = try alice.encrypt( let oldTransport = try alice.encrypt(
Data("timeout rollback".utf8), Data("timeout rollback".utf8),
for: bobPeerID for: bobPeerID