Skip to content
Merged
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
112 changes: 112 additions & 0 deletions .github/workflows/1874-diagnose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
name: 1874 Diagnose

on:
workflow_dispatch:
inputs:
iterations:
description: 'Loop iterations per job'
required: false
default: '25'
push:
branches:
- diagnose/1874-commit-stall

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
stall-loop:
name: ${{ matrix.arch }} ${{ matrix.mode }} stall loop
runs-on: macos-26
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
mode: [isolated, pair]
arch: [x86_64, arm64]
env:
IOS_RUNTIME_VERSION: '26.2'
AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ github.workspace }}/.tmp/ios-runner-derived
AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS: '1'
AGENT_DEVICE_XCUITEST_ARCHS: ${{ matrix.arch }}
steps:
- uses: actions/checkout@v4

- name: Setup toolchain
uses: ./.github/actions/setup-node-pnpm

- name: Restore and build iOS XCTest runner
uses: ./.github/actions/setup-apple-runner-build
with:
derived-path: ${{ env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH }}
cache-key-prefix: ios-runner-prebuilt
cache-key-suffix: -ios-${{ env.IOS_RUNTIME_VERSION }}-1874-${{ matrix.arch }}
gate: swift-runner-ios
xcuitest-platform: ios
xcuitest-destination: generic/platform=iOS Simulator

- name: Boot iOS test simulator
id: ios-simulator
uses: ./.github/actions/boot-ios-test-simulator
with:
runtime-version: ${{ env.IOS_RUNTIME_VERSION }}
preferred-device-name: iPhone 17 Pro

- name: Stall loop
run: |
set -euo pipefail
XCTESTRUN_PATH="$(find "$AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH/Build/Products" -maxdepth 1 -name '*.xctestrun' -print -quit)"
test -n "$XCTESTRUN_PATH"
UDID="${{ steps.ios-simulator.outputs.simulator-udid }}"
ITER="${{ github.event.inputs.iterations || '25' }}"

if [ "${{ matrix.mode }}" = "pair" ]; then
EXTRA_ONLY_TESTING=(-only-testing:AgentDeviceRunnerUITests/RunnerTests/testBareDelayedTypeFailsWhenTappedInputDisappearsMidCommand)
else
EXTRA_ONLY_TESTING=()
fi

sys_prof="$(system_profiler SPHardwareDataType 2>/dev/null | grep -E 'Chip|Cores|Memory' || true)"
echo "HOST: $sys_prof" | tee stall-summary.txt

pass=0; fail=0
for i in $(seq 1 "$ITER"); do
LOG=".tmp/stall-run-$i.log"
set +e
xcodebuild test-without-building \
-xctestrun "$XCTESTRUN_PATH" \
-destination "platform=iOS Simulator,id=$UDID" \
-only-testing:AgentDeviceRunnerUITests/RunnerTests/testBareTypeUsesTappedInputWhenSoftwareKeyboardIsHidden \
${EXTRA_ONLY_TESTING[@]+"${EXTRA_ONLY_TESTING[@]}"} \
> "$LOG" 2>&1
rc=$?
set -e
if grep -q "testBareTypeUsesTappedInputWhenSoftwareKeyboardIsHidden]' passed" "$LOG"; then
pass=$((pass+1)); verdict=pass
else
fail=$((fail+1)); verdict=STALL
fi
phase="$(grep -o 'phase=type-all durationMs=[0-9.]*' "$LOG" | tail -1)"
polls="$(grep -c 'DEBUG-1874] poll' "$LOG" || true)"
echo "iter=$i verdict=$verdict rc=$rc $phase polls=$polls" | tee -a stall-summary.txt
if [ "$verdict" = "STALL" ]; then
grep 'DEBUG-1874' "$LOG" | head -40 | tee -a stall-summary.txt
cp "$LOG" ".tmp/stall-failure-$i.log"
fi
done

echo "RESULT arch=${{ matrix.arch }} mode=${{ matrix.mode }}: $pass passed, $fail stalled of $ITER" | tee -a stall-summary.txt

- name: Upload stall evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: stall-evidence-${{ matrix.arch }}-${{ matrix.mode }}
path: |
stall-summary.txt
.tmp/stall-failure-*.log
if-no-files-found: ignore
3 changes: 2 additions & 1 deletion .github/workflows/conformance-differential.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ jobs:
timeout-minutes: 90
env:
IOS_RUNTIME_VERSION: '26.2'
AGENT_DEVICE_XCUITEST_ARCHS: 'arm64'
AGENT_DEVICE_STATE_DIR: ${{ github.workspace }}/.tmp/agent-device-state
AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ github.workspace }}/.tmp/ios-runner-derived
steps:
Expand All @@ -59,7 +60,7 @@ jobs:
with:
derived-path: ${{ env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH }}
cache-key-prefix: ios-runner-prebuilt
cache-key-suffix: -ios-${{ env.IOS_RUNTIME_VERSION }}
cache-key-suffix: -ios-${{ env.IOS_RUNTIME_VERSION }}-arm64
gate: swift-runner-ios
xcuitest-platform: ios
xcuitest-destination: generic/platform=iOS Simulator
Expand Down
7 changes: 6 additions & 1 deletion .github/workflows/ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ jobs:
AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ github.workspace }}/.tmp/ios-runner-derived
AGENT_DEVICE_IOS_PREPARE_TIMEOUT_MS: '420000'
AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS: '1'
# A generic simulator destination leaves the active arch undefined and Xcode 26.6
# defaults it to x86_64, which runs the whole runner under Rosetta on arm64 hosts.
# Pin the native slice: measured ~30% faster commits on identical CI hardware
# (#1874 diagnosis, PR #1941).
AGENT_DEVICE_XCUITEST_ARCHS: 'arm64'
AGENT_DEVICE_IOS_APP_EVENT_URL_TEMPLATE: agent-device-test-app:///automation?event={event}&payload={payload}
steps:
- name: Checkout
Expand Down Expand Up @@ -113,7 +118,7 @@ jobs:
with:
derived-path: ${{ env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH }}
cache-key-prefix: ios-runner-prebuilt
cache-key-suffix: -ios-${{ env.IOS_RUNTIME_VERSION }}
cache-key-suffix: -ios-${{ env.IOS_RUNTIME_VERSION }}-arm64
gate: swift-runner-ios
xcuitest-platform: ios
xcuitest-destination: generic/platform=iOS Simulator
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/perf-nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ jobs:
timeout-minutes: 80
env:
IOS_RUNTIME_VERSION: '26.2'
AGENT_DEVICE_XCUITEST_ARCHS: 'arm64'
AGENT_DEVICE_STATE_DIR: ${{ github.workspace }}/.tmp/agent-device-state
AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ github.workspace }}/.tmp/ios-runner-derived
AGENT_DEVICE_IOS_PREPARE_TIMEOUT_MS: '420000'
Expand All @@ -47,7 +48,7 @@ jobs:
with:
derived-path: ${{ env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH }}
cache-key-prefix: ios-runner-prebuilt
cache-key-suffix: -ios-${{ env.IOS_RUNTIME_VERSION }}
cache-key-suffix: -ios-${{ env.IOS_RUNTIME_VERSION }}-arm64
gate: swift-runner-ios
xcuitest-platform: ios
xcuitest-destination: generic/platform=iOS Simulator
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/replays-manual.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ jobs:
timeout-minutes: 80
env:
IOS_RUNTIME_VERSION: '26.2'
AGENT_DEVICE_XCUITEST_ARCHS: 'arm64'
AGENT_DEVICE_STATE_DIR: ${{ github.workspace }}/.tmp/agent-device-state
AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ github.workspace }}/.tmp/ios-runner-derived
AGENT_DEVICE_IOS_PREPARE_TIMEOUT_MS: '420000'
Expand All @@ -111,7 +112,7 @@ jobs:
with:
derived-path: ${{ env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH }}
cache-key-prefix: ios-runner-prebuilt
cache-key-suffix: -ios-${{ env.IOS_RUNTIME_VERSION }}
cache-key-suffix: -ios-${{ env.IOS_RUNTIME_VERSION }}-arm64
gate: swift-runner-ios
xcuitest-platform: ios
xcuitest-destination: generic/platform=iOS Simulator
Expand Down
6 changes: 4 additions & 2 deletions .github/workflows/xctest-nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,10 @@ jobs:
# methods exist at all. Dropping it would build a different variant (cache miss) whose
# bundle contains no tests, and `xcodebuild` reports running zero tests as success — the
# "silent 0-test green" this lane would then be. The test-count assertion below is the
# backstop for exactly that.
# backstop for exactly that. AGENT_DEVICE_XCUITEST_ARCHS rides the cache key too: dropping
# it silently reverts the runner to Xcode's x86_64 default under Rosetta.
IOS_RUNTIME_VERSION: '26.2'
AGENT_DEVICE_XCUITEST_ARCHS: 'arm64'
AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS: '1'
AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ github.workspace }}/.tmp/ios-runner-derived
RESULT_BUNDLE_PATH: ${{ github.workspace }}/.tmp/xctest-nightly/RunnerTests.xcresult
Expand All @@ -103,7 +105,7 @@ jobs:
with:
derived-path: ${{ env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH }}
cache-key-prefix: ios-runner-prebuilt
cache-key-suffix: -ios-${{ env.IOS_RUNTIME_VERSION }}
cache-key-suffix: -ios-${{ env.IOS_RUNTIME_VERSION }}-arm64
gate: swift-runner-ios
xcuitest-platform: ios
xcuitest-destination: generic/platform=iOS Simulator
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,14 @@ extension RunnerTests {
replacingExistingText: Bool
) -> SynthesizedTextEntryAction {
#if os(iOS)
let postStartedAt = Date()
let result = replacingExistingText
? RunnerSynthesizedTextEntry.replaceText(withApplication: app, text: text)
: RunnerSynthesizedTextEntry.synthesizeText(withApplication: app, text: text)
NSLog(
"[DEBUG-1874] synthesize posted %d chars status=%d tookMs=%.0f",
text.count, result.status.rawValue, postStartedAt.timeIntervalSinceNow * -1000
)
return Self.action(status: result.status, message: result.message)
#else
return .fallback
Expand Down Expand Up @@ -184,6 +189,47 @@ extension RunnerTests {
return expectedText.hasPrefix(observedText) ? .pending : .diverged
}

/// Length of the shared prefix of two strings. Feeds value-free commit-wait logging: the
/// expected-prefix walk over time distinguishes throttled delivery (grows slowly) from a
/// wedged pipeline (freezes) without ever logging the field's contents.
static func commonPrefixLength(_ lhs: String, _ rhs: String) -> Int {
var length = 0
for (l, r) in zip(lhs, rhs) {
if l != r { break }
length += 1
}
return length
}

/// The emitted cadence line, as a pure function so its output is assertable. Only lengths and
/// a timestamp are representable here; there is no String parameter, so observed field
/// contents cannot reach runner.log through this boundary whatever they contain.
static func commitCadenceLogLine(
elapsedMs: Int,
observedLen: Int,
expectedPrefixLen: Int
) -> String {
"[DEBUG-1874] poll t=\(elapsedMs)ms observedLen=\(observedLen) expectedPrefixLen=\(expectedPrefixLen)"
}

/// The typed boundary for commit-wait cadence evidence. The poll path must log through this
/// function and never through a raw NSLog: every parameter is an Int, so the polled value's
/// contents are unrepresentable at the call site.
static func logCommitCadence(
elapsedMs: Int,
observedLen: Int,
expectedPrefixLen: Int
) {
NSLog(
"%@",
commitCadenceLogLine(
elapsedMs: elapsedMs,
observedLen: observedLen,
expectedPrefixLen: expectedPrefixLen
)
)
}

/// How the commit wait ended. Distinct from `SynthesizedTextCommitProgress`, which classifies a
/// single observation: this is the whole wait's verdict, and it exists so the deadline can be
/// told apart from success. The wait used to return `Void`, which made an expired deadline
Expand Down Expand Up @@ -265,20 +311,37 @@ extension RunnerTests {
let expectedText = textBefore + typedText
let placeholder = resolveTextEntryElement(app: app, target: target)?.placeholderValue
let deadline = Date().addingTimeInterval(TextEntryTiming.synthesizedCommitTimeout)
return Self.awaitSynthesizedCommitOutcome(
let waitStartedAt = Date()
NSLog("[DEBUG-1874] wait start expectedLen=%ld", expectedText.count)
let outcome = Self.awaitSynthesizedCommitOutcome(
expectedText: expectedText,
placeholder: placeholder,
isExpired: { Date() >= deadline },
observe: {
editableTextValue(
let observedText = editableTextValue(
for: resolveTextEntryElement(app: app, target: target),
treatingPlaceholderAsEmpty: true
)
// Cadence evidence stays value-free: the polled value is user content typed through
// `type` and must never reach runner.log. Lengths and the expected-prefix walk are
// enough to distinguish throttling (prefix grows slowly) from a wedge (it freezes).
Self.logCommitCadence(
elapsedMs: Int(waitStartedAt.timeIntervalSinceNow * -1000),
observedLen: observedText?.count ?? -1,
expectedPrefixLen: observedText.map { Self.commonPrefixLength($0, expectedText) } ?? -1
)
return observedText
},
// XCUI resolution shares the automation channel with the in-flight synthesized event.
// Sparse reads let the target consume that event instead of continuously interrupting it.
waitForNextObservation: { sleepFor(TextEntryTiming.synthesizedCommitPollInterval) }
)
NSLog(
"[DEBUG-1874] wait outcome=%@ elapsedMs=%.0f",
String(describing: outcome),
waitStartedAt.timeIntervalSinceNow * -1000
)
return outcome
}

static func shouldUseResolvedCoordinateTextEntryRoute(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,31 @@ extension RunnerTests {
XCTAssertFalse(result.repaired)
XCTAssertEqual(result.textEntryRoute, "synthesized-first-responder-replacement")
}

func testCommonPrefixLengthWalksTheExpectedPrefixOnly() {
XCTAssertEqual(Self.commonPrefixLength("hardware-keyboard", "hardware-keyboard"), 17)
XCTAssertEqual(Self.commonPrefixLength("h", "hardware-keyboard"), 1)
XCTAssertEqual(Self.commonPrefixLength("ha", "hardware-keyboard"), 2)
XCTAssertEqual(Self.commonPrefixLength("", "hardware-keyboard"), 0)
// Divergence stops the count: the app transformed the input, and the walk must not
// resume matching after the first differing character.
XCTAssertEqual(Self.commonPrefixLength("hx", "hardware-keyboard"), 1)
XCTAssertEqual(Self.commonPrefixLength("hardware-keyboarx", "hardware-keyboard"), 15)
}

func testCommitCadenceLogLineEmitsLengthsOnlyNeverContents() {
// Sentinel secret: even when the polled field holds credential-shaped content, the only
// channel into runner.log is this line, and its inputs are lengths. The exact-equality
// assert fails if any content-bearing parameter or interpolation is ever added.
let secret = "hunter2-typed-credential"
let line = Self.commitCadenceLogLine(
elapsedMs: 42,
observedLen: secret.count,
expectedPrefixLen: 8
)
XCTAssertEqual(line, "[DEBUG-1874] poll t=42ms observedLen=24 expectedPrefixLen=8")
XCTAssertFalse(line.contains(secret))
}
#endif
#endif
}
9 changes: 9 additions & 0 deletions scripts/build-xcuitest-apple.sh
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,14 @@ if is_truthy "${AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS:-}"; then
SWIFT_FLAGS="$SWIFT_FLAGS -D AGENT_DEVICE_RUNNER_UNIT_TESTS"
fi

# Optional arch override. A generic simulator destination leaves the active arch
# undefined; Xcode versions differ on the default (26.6 picks x86_64, which runs
# under Rosetta on arm64 hosts). Set AGENT_DEVICE_XCUITEST_ARCHS=arm64 to pin it.
ARCH_BUILD_SETTINGS=""
if [ -n "${AGENT_DEVICE_XCUITEST_ARCHS:-}" ]; then
ARCH_BUILD_SETTINGS="ARCHS=$AGENT_DEVICE_XCUITEST_ARCHS"
fi

node --experimental-strip-types scripts/swift-toolchain-tmpdir.ts xcodebuild build-for-testing \
-project "$PROJECT_PATH" \
-scheme "$SCHEME" \
Expand All @@ -154,6 +162,7 @@ node --experimental-strip-types scripts/swift-toolchain-tmpdir.ts xcodebuild bui
-IDEPackageSupportDisablePluginExecutionSandbox=1 \
ENABLE_USER_SCRIPT_SANDBOXING=NO \
OTHER_SWIFT_FLAGS="$SWIFT_FLAGS" \
$ARCH_BUILD_SETTINGS \
$SIGNING_BUILD_SETTINGS

node --experimental-strip-types scripts/patch-xcuitest-runner-icon.ts "$DERIVED_PATH"
Expand Down
Loading
Loading