diff --git a/docs/perf-testing.md b/docs/perf-testing.md new file mode 100644 index 000000000..e01aefbac --- /dev/null +++ b/docs/perf-testing.md @@ -0,0 +1,108 @@ +--- +summary: How to run and interpret the iOS typing performance audit commands and outputs. +read_when: + - You need avg/best/worst keypress latency from the composer. + - You are profiling UI-thread work during chat composer typing. + - You are troubleshooting samply vs xctrace profiling on iOS simulator. +--- + +# iOS Typing Perf Testing + +This document describes the current typing performance audit flow for the iOS app, focused on one question: + +- Does typing into the chat composer block or stall the UI thread? + +## Current scope + +The current automated flow measures typing in a note-to-self chat and produces: + +- Keypress latency summary (`avg`, `best`, `worst`) from UI tests. +- Symbolized stack sampling for work happening during keypress windows. +- Flamegraph output for per-keypress averaged stacks. + +This is a first pass and does not yet preload a synthetic 10k-message chat fixture. + +## One-liner summary + +Run: + +```bash +just ios-typing-perf +``` + +Output includes a single parseable summary line: + +```text +typing-keypress-ms avg= best= worst= samples= [bulk_total= bulk_per_char=] +``` + +## Symbolized keypress profile + flamegraph + +Run: + +```bash +just ios-typing-perf-keypress-profile-symbolized +``` + +This runs the typing test, attaches Time Profiler to the app process, and emits: + +- `typing-keypress-ms ...` summary. +- `typing-keypress-top ...` ranked sampled frames during keypress intervals. +- `typing-keypress-folded path=...` folded stacks file. +- `typing-keypress-flamegraph path=...` SVG flamegraph. +- `time-profiler-trace path=...` trace bundle. + +Add `--open` to auto-open the trace/flamegraph viewer path where applicable. + +## Samply route + +Run: + +```bash +just ios-typing-perf-samply +``` + +Notes: + +- `samply` is available in the dev shell via `flake.nix`. +- On macOS simulator attach, samply may panic with an upstream `InvalidAddress` issue. +- The script auto-codesigns a local `samply` copy for attach profiling. +- If samply attach remains unstable, the script falls back to `ios-typing-perf-keypress-profile-symbolized` by default so the command still returns useful output. + +Disable fallback if you want strict samply-only behavior: + +```bash +PIKA_UI_TYPING_PERF_SAMPLY_FALLBACK_XCTRACE=0 just ios-typing-perf-samply +``` + +## Key instrumentation points + +- UI tests emit keypress signposts (`composer_keypress`) around measured keystrokes. +- App-side signposts can also be enabled to bracket composer text-change handling. +- Time Profiler analysis intersects sampled stacks with keypress intervals and reports per-keypress averages. + +## Environment knobs + +Common knobs: + +- `PIKA_UI_TYPING_PERF_CHARS` (default `120`) +- `PIKA_UI_TYPING_PERF_WARMUP` (default `10`) +- `PIKA_UI_TYPING_PERF_START_DELAY_MS` (default varies by script) +- `PIKA_UI_TYPING_PERF_TRACE_DIR` (default `artifacts/perf`) + +Samply-specific knobs: + +- `PIKA_UI_TYPING_PERF_SAMPLY_ATTACH_ATTEMPTS` +- `PIKA_UI_TYPING_PERF_SAMPLY_ATTACH_DELAY_S` +- `PIKA_UI_TYPING_PERF_SAMPLY_ATTACH_TIMEOUT_S` +- `PIKA_UI_TYPING_PERF_SAMPLY_FALLBACK_XCTRACE` + +## iOS runtime / destination troubleshooting + +If builds fail with destination/runtime mismatch, run: + +```bash +just doctor-ios +``` + +The updated iOS tooling scripts prefer an Xcode + simulator runtime pairing compatible with the active `iphonesimulator` SDK and print guidance when runtime components are missing. diff --git a/flake.nix b/flake.nix index ed36dae38..f7fa34501 100644 --- a/flake.nix +++ b/flake.nix @@ -345,6 +345,8 @@ pkgs.findutils pkgs.gnugrep pkgs.gnused + pkgs.inferno + pkgs.samply cargoDinghy pkgs.age pkgs.age-plugin-yubikey diff --git a/ios/Sources/Views/ChatView.swift b/ios/Sources/Views/ChatView.swift index 60b3e575b..02bf06f82 100644 --- a/ios/Sources/Views/ChatView.swift +++ b/ios/Sources/Views/ChatView.swift @@ -3,6 +3,7 @@ import MarkdownUI import PhotosUI import AVFAudio import UniformTypeIdentifiers +import os.signpost struct ChatView: View { let chatId: String @@ -47,6 +48,7 @@ struct ChatView: View { @FocusState private var isInputFocused: Bool private let scrollButtonBottomPadding: CGFloat = 12 + private static let typingPerfSignpostLog = OSLog(subsystem: "org.pikachat.pika", category: .pointsOfInterest) init( chatId: String, @@ -645,7 +647,21 @@ struct ChatView: View { onSend: { sendMessage() }, onStartVoiceRecording: { startVoiceRecording() } ) - .onChangeCompat(of: messageText) { newValue in + .onChangeCompat(of: messageText, withOld: { oldValue, newValue in + let signpostEnabled = envBool("PIKA_UI_TYPING_PERF_APP_SIGNPOSTS", defaultValue: false) + let lengthDelta = newValue.count - oldValue.count + let likelyKeypress = isInputFocused && abs(lengthDelta) == 1 + var signpostID: OSSignpostID? + if signpostEnabled && likelyKeypress { + let id = OSSignpostID(log: Self.typingPerfSignpostLog) + os_signpost(.begin, log: Self.typingPerfSignpostLog, name: "composer_keypress", signpostID: id, "delta=%{public}d", lengthDelta) + signpostID = id + } + defer { + if let signpostID { + os_signpost(.end, log: Self.typingPerfSignpostLog, name: "composer_keypress", signpostID: signpostID, "delta=%{public}d", lengthDelta) + } + } if chat.isGroup { if let atIdx = newValue.lastIndex(of: "@") { let prefix = newValue[.. Bool { + let raw = (ProcessInfo.processInfo.environment[key] ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + if raw.isEmpty { return defaultValue } + if raw == "1" || raw == "true" || raw == "yes" || raw == "on" { return true } + if raw == "0" || raw == "false" || raw == "no" || raw == "off" { return false } + return defaultValue + } + private func startVoiceRecording() { Task { let granted = await CallMicrophonePermission.ensureGranted() diff --git a/ios/UITests/TypingPerfUITests.swift b/ios/UITests/TypingPerfUITests.swift new file mode 100644 index 000000000..2e305298b --- /dev/null +++ b/ios/UITests/TypingPerfUITests.swift @@ -0,0 +1,203 @@ +import XCTest +import QuartzCore +import os.signpost + +final class TypingPerfUITests: XCTestCase { + private let typingSignpostLog = OSLog(subsystem: "org.pikachat.pika.uitests", category: .pointsOfInterest) + + func testTypingLatencySummary() throws { + let app = XCUIApplication() + app.launchEnvironment["PIKA_UI_TEST_RESET"] = "1" + app.launchEnvironment["PIKA_DISABLE_NETWORK"] = "1" + app.launchEnvironment["PIKA_UI_TYPING_PERF_APP_SIGNPOSTS"] = ProcessInfo.processInfo.environment["PIKA_UI_TYPING_PERF_APP_SIGNPOSTS"] ?? "1" + app.launch() + + ensureLoggedIn(app) + let myNpub = openProfileAndReadNpub(app) + closeProfile(app) + openNewChatFromChatList(app) + openNoteToSelfChat(app, npub: myNpub) + + let composer = waitForChatComposer(app, timeout: 10) + XCTAssertTrue(composer.waitForExistence(timeout: 10), "Composer missing") + composer.tap() + + let warmupChars = envInt("PIKA_UI_TYPING_PERF_WARMUP", defaultValue: 10) + let measuredChars = envInt("PIKA_UI_TYPING_PERF_CHARS", defaultValue: 120) + let startDelayMs = envInt("PIKA_UI_TYPING_PERF_START_DELAY_MS", defaultValue: 0) + let sampleText = makeSampleText(length: warmupChars + measuredChars) + let keypressSignposts = envBool("PIKA_UI_TYPING_PERF_SIGNPOSTS", defaultValue: true) + + if startDelayMs > 0 { + Thread.sleep(forTimeInterval: TimeInterval(startDelayMs) / 1_000.0) + } + + var keypressTimesMs: [Double] = [] + keypressTimesMs.reserveCapacity(measuredChars) + + for (idx, ch) in sampleText.enumerated() { + let isMeasured = idx >= warmupChars + let measuredIdx = idx - warmupChars + var signpostID: OSSignpostID? + if keypressSignposts, isMeasured { + let id = OSSignpostID(log: typingSignpostLog) + os_signpost(.begin, log: typingSignpostLog, name: "composer_keypress", signpostID: id, "index=%{public}d", measuredIdx) + signpostID = id + } + + let start = CACurrentMediaTime() + composer.typeText(String(ch)) + let elapsedMs = (CACurrentMediaTime() - start) * 1_000.0 + if isMeasured { + keypressTimesMs.append(elapsedMs) + } + if let signpostID { + os_signpost(.end, log: typingSignpostLog, name: "composer_keypress", signpostID: signpostID, "index=%{public}d", measuredIdx) + } + } + + let avg = keypressTimesMs.reduce(0.0, +) / Double(max(1, keypressTimesMs.count)) + let best = keypressTimesMs.min() ?? 0 + let worst = keypressTimesMs.max() ?? 0 + print( + String( + format: "PIKA_TYPING_PERF avg_ms=%.3f best_ms=%.3f worst_ms=%.3f samples=%d warmup=%d", + avg, + best, + worst, + keypressTimesMs.count, + warmupChars + ) + ) + } + + func testTypingBulkSummary() throws { + let app = XCUIApplication() + app.launchEnvironment["PIKA_UI_TEST_RESET"] = "1" + app.launchEnvironment["PIKA_DISABLE_NETWORK"] = "1" + app.launchEnvironment["PIKA_UI_TYPING_PERF_APP_SIGNPOSTS"] = ProcessInfo.processInfo.environment["PIKA_UI_TYPING_PERF_APP_SIGNPOSTS"] ?? "1" + app.launch() + + ensureLoggedIn(app) + let myNpub = openProfileAndReadNpub(app) + closeProfile(app) + openNewChatFromChatList(app) + openNoteToSelfChat(app, npub: myNpub) + + let composer = waitForChatComposer(app, timeout: 10) + XCTAssertTrue(composer.waitForExistence(timeout: 10), "Composer missing") + composer.tap() + + let chars = envInt("PIKA_UI_TYPING_PERF_CHARS", defaultValue: 120) + let text = makeSampleText(length: chars) + + let signpostID = OSSignpostID(log: typingSignpostLog) + os_signpost(.begin, log: typingSignpostLog, name: "composer_bulk_typeText", signpostID: signpostID, "chars=%{public}d", chars) + let start = CACurrentMediaTime() + composer.typeText(text) + let totalMs = (CACurrentMediaTime() - start) * 1_000.0 + os_signpost(.end, log: typingSignpostLog, name: "composer_bulk_typeText", signpostID: signpostID, "chars=%{public}d", chars) + + let perCharMs = totalMs / Double(max(1, chars)) + print(String(format: "PIKA_TYPING_BULK total_ms=%.3f per_char_ms=%.3f chars=%d", totalMs, perCharMs, chars)) + } + + private func envInt(_ key: String, defaultValue: Int) -> Int { + let raw = ProcessInfo.processInfo.environment[key] ?? "" + guard let parsed = Int(raw), parsed > 0 else { return defaultValue } + return parsed + } + + private func envBool(_ key: String, defaultValue: Bool) -> Bool { + let raw = (ProcessInfo.processInfo.environment[key] ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if raw.isEmpty { return defaultValue } + if raw == "1" || raw == "true" || raw == "yes" || raw == "on" { return true } + if raw == "0" || raw == "false" || raw == "no" || raw == "off" { return false } + return defaultValue + } + + private func makeSampleText(length: Int) -> String { + let seed = "the_quick_brown_fox_jumps_over_the_lazy_dog_0123456789 " + guard length > 0 else { return "" } + var out = "" + out.reserveCapacity(length) + while out.count < length { + out += seed + } + return String(out.prefix(length)) + } + + private func ensureLoggedIn(_ app: XCUIApplication) { + let createAccount = app.buttons.matching(identifier: "login_create_account").firstMatch + if createAccount.waitForExistence(timeout: 2) { + createAccount.tap() + } + let chatsNavBar = app.navigationBars["Chats"] + XCTAssertTrue(chatsNavBar.waitForExistence(timeout: 15), "Chats screen not visible") + } + + private func openProfileAndReadNpub(_ app: XCUIApplication) -> String { + let myNpubBtn = app.buttons.matching(identifier: "chatlist_my_npub").firstMatch + XCTAssertTrue(myNpubBtn.waitForExistence(timeout: 5), "Profile button missing") + myNpubBtn.tap() + + let profileNav = app.navigationBars["Profile"] + XCTAssertTrue(profileNav.waitForExistence(timeout: 5), "Profile sheet missing") + + let npubValue = app.staticTexts.matching(identifier: "chatlist_my_npub_value").firstMatch + XCTAssertTrue(npubValue.waitForExistence(timeout: 5), "npub value missing") + let npub = npubValue.label + XCTAssertTrue(npub.hasPrefix("npub1"), "Expected npub1..., got: \(npub)") + return npub + } + + private func closeProfile(_ app: XCUIApplication) { + let close = app.buttons.matching(identifier: "chatlist_my_npub_close").firstMatch + if close.exists { + close.tap() + } else { + app.navigationBars["Profile"].buttons.element(boundBy: 0).tap() + } + } + + private func openNewChatFromChatList(_ app: XCUIApplication) { + let newChat = app.buttons.matching(identifier: "chatlist_new_chat").firstMatch + XCTAssertTrue(newChat.waitForExistence(timeout: 5), "New chat button missing") + newChat.tap() + + let nav = app.navigationBars["New Chat"] + if nav.waitForExistence(timeout: 2) { + return + } + + let menuItem = app.buttons["New Chat"].firstMatch + XCTAssertTrue(menuItem.waitForExistence(timeout: 5), "New Chat menu item missing") + menuItem.tap() + XCTAssertTrue(nav.waitForExistence(timeout: 10), "New Chat screen not visible") + } + + private func openNoteToSelfChat(_ app: XCUIApplication, npub: String) { + let peer = app.descendants(matching: .any).matching(identifier: "newchat_peer_npub").firstMatch + XCTAssertTrue(peer.waitForExistence(timeout: 10), "Peer npub field missing") + peer.tap() + peer.typeText(npub) + + let start = app.buttons.matching(identifier: "newchat_start").firstMatch + XCTAssertTrue(start.waitForExistence(timeout: 5), "Start chat button missing") + start.tap() + } + + private func waitForChatComposer(_ app: XCUIApplication, timeout: TimeInterval) -> XCUIElement { + let textView = app.textViews.matching(identifier: "chat_message_input").firstMatch + let textField = app.textFields.matching(identifier: "chat_message_input").firstMatch + let deadline = Date().addingTimeInterval(timeout) + + while Date() < deadline { + if textView.exists { return textView } + if textField.exists { return textField } + Thread.sleep(forTimeInterval: 0.1) + } + + return textView + } +} diff --git a/justfile b/justfile index c6b1bb0ac..1603caa2f 100644 --- a/justfile +++ b/justfile @@ -731,6 +731,26 @@ ios-ui-test: ios-xcframework ios-xcodeproj ./tools/xcode-run xcodebuild -project ios/Pika.xcodeproj -scheme Pika -derivedDataPath ios/build -destination "id=$udid" test -skipMacroValidation ARCHS=arm64 ONLY_ACTIVE_ARCH=YES CODE_SIGNING_ALLOWED=NO PIKA_APP_BUNDLE_ID="${PIKA_IOS_BUNDLE_ID:-org.pikachat.pika.dev}" PIKA_IOS_URL_SCHEME="${PIKA_IOS_URL_SCHEME:-pika}" \ -skip-testing:PikaUITests/PikaUITests/testE2E_deployedRustBot_pingPong +# Run iOS typing perf test and print avg/best/worst keypress latency. +ios-typing-perf *ARGS: ios-xcframework ios-xcodeproj + ./tools/ios-typing-perf {{ ARGS }} + +# Run iOS typing perf summary + capture/open a Time Profiler trace. +ios-typing-perf-trace *ARGS: ios-xcframework ios-xcodeproj + ./tools/ios-typing-perf-trace {{ ARGS }} + +# Run iOS typing perf and print averaged per-keypress stack profile from Time Profiler. +ios-typing-perf-keypress-profile *ARGS: ios-xcframework ios-xcodeproj + ./tools/ios-typing-perf-keypress-profile {{ ARGS }} + +# Run iOS typing perf with attach-mode symbolized stacks and per-keypress flamegraph output. +ios-typing-perf-keypress-profile-symbolized *ARGS: ios-xcframework ios-xcodeproj + ./tools/ios-typing-perf-keypress-profile-symbolized {{ ARGS }} + +# Run iOS typing perf while recording a Samply profile for the Pika app process. +ios-typing-perf-samply *ARGS: ios-xcframework ios-xcodeproj + ./tools/ios-typing-perf-samply {{ ARGS }} + # iOS E2E: local Nostr relay + local Rust bot. ios-ui-e2e-local: cargo test -p pikahut --test integration_deterministic ui_e2e_local_ios -- --ignored --nocapture diff --git a/tools/ios-runtime-doctor b/tools/ios-runtime-doctor index 6fe38f77a..f89d9969b 100755 --- a/tools/ios-runtime-doctor +++ b/tools/ios-runtime-doctor @@ -18,10 +18,67 @@ has_runtimes() { [ -n "$("$SIMCTL" list runtimes | sed -n '2,$p' | tr -d '[:space:]')" ] } +ios_sim_sdk="$( + DEVELOPER_DIR="$DEV_DIR" /usr/bin/xcrun --sdk iphonesimulator --show-sdk-version 2>/dev/null || true +)" + +has_compatible_runtime_for_sdk() { + python3 - "$ios_sim_sdk" "$SIMCTL" <<'PY' +import json +import re +import subprocess +import sys + +sdk = (sys.argv[1] if len(sys.argv) > 1 else "").strip() +simctl = sys.argv[2] +m = re.match(r"^(\d+)(?:\.(\d+))?", sdk) +if not m: + sys.exit(0) +sdk_major = int(m.group(1)) +sdk_minor = int(m.group(2) or 0) + +j = json.loads(subprocess.check_output([simctl, "list", "-j", "runtimes"], text=True)) +for rt in j.get("runtimes", []): + ident = rt.get("identifier") or "" + name = rt.get("name") or "" + if not name.startswith("iOS "): + continue + if rt.get("isAvailable") is False: + continue + rm = re.search(r"iOS-(\d+)-(\d+)$", ident) + if not rm: + continue + major = int(rm.group(1)) + minor = int(rm.group(2)) + if major == sdk_major and minor >= sdk_minor: + sys.exit(0) +sys.exit(1) +PY +} + if has_runtimes; then - echo "ok: iOS Simulator runtimes are installed." - "$SIMCTL" list runtimes | sed -n '1,40p' || true - exit 0 + if has_compatible_runtime_for_sdk; then + echo "ok: iOS Simulator runtimes are installed." + if [ -n "$ios_sim_sdk" ]; then + echo "ok: found a runtime compatible with iphonesimulator SDK $ios_sim_sdk." + fi + "$SIMCTL" list runtimes | sed -n '1,40p' || true + exit 0 + fi + cat >&2 < Settings -> Platforms + 3) Install "iOS Simulator ${ios_sim_sdk:-}" + +Installed runtimes: +EOF + "$SIMCTL" list runtimes | sed -n '1,80p' >&2 || true + exit 1 fi XCODES_BIN="$(command -v xcodes 2>/dev/null || true)" diff --git a/tools/ios-sim-ensure b/tools/ios-sim-ensure index ebe4dac90..747fe7073 100755 --- a/tools/ios-sim-ensure +++ b/tools/ios-sim-ensure @@ -19,34 +19,60 @@ if [ -z "$("$SIMCTL" list runtimes | sed -n '2,$p' | tr -d '[:space:]')" ]; then exit 1 fi -# Pick the latest available iOS runtime identifier. `simctl list runtimes` prints both -# a human-readable version/build in parentheses and the identifier after " - ". -# Example: -# iOS 18.6 (18.6 - 22G86) - com.apple.CoreSimulator.SimRuntime.iOS-18-6 +ios_sim_sdk="$( + DEVELOPER_DIR="$DEV_DIR" /usr/bin/xcrun --sdk iphonesimulator --show-sdk-version 2>/dev/null || true +)" + +# Pick an available iOS runtime identifier compatible with the current Xcode +# iphonesimulator SDK (prefer same major and >= minor; else fail with guidance). runtime_id="$( - "$SIMCTL" list -j runtimes | python3 -c ' -import json,sys,re -j=json.load(sys.stdin) -r=[] -for rt in j.get("runtimes",[]): - ident=rt.get("identifier") or "" - name=rt.get("name") or "" - avail=rt.get("isAvailable") - if not name.startswith("iOS "): - continue - if avail is False: - continue - m=re.search(r"iOS-(\d+)-(\d+)$", ident) - if not m: - continue - r.append((int(m.group(1)), int(m.group(2)), ident)) -r.sort() -sys.stdout.write(r[-1][2] if r else "") -' + python3 - "$ios_sim_sdk" "$SIMCTL" <<'PY' +import json +import re +import subprocess +import sys + +sdk_raw = (sys.argv[1] if len(sys.argv) > 1 else "").strip() +simctl = sys.argv[2] +sdk_major = None +sdk_minor = None +sdk_match = re.match(r"^(\d+)(?:\.(\d+))?", sdk_raw) +if sdk_match: + sdk_major = int(sdk_match.group(1)) + sdk_minor = int(sdk_match.group(2) or 0) + +j = json.loads(subprocess.check_output([simctl, "list", "-j", "runtimes"], text=True)) +runtimes = [] +for rt in j.get("runtimes", []): + ident = rt.get("identifier") or "" + name = rt.get("name") or "" + avail = rt.get("isAvailable") + if not name.startswith("iOS "): + continue + if avail is False: + continue + m = re.search(r"iOS-(\d+)-(\d+)$", ident) + if not m: + continue + runtimes.append((int(m.group(1)), int(m.group(2)), ident)) + +runtimes.sort() +if not runtimes: + print("") + sys.exit(0) + +if sdk_major is None: + print(runtimes[-1][2]) + sys.exit(0) + +compatible = [rt for rt in runtimes if rt[0] == sdk_major and rt[1] >= sdk_minor] +print(compatible[-1][2] if compatible else "") +PY )" if [ -z "${runtime_id:-}" ]; then - echo "error: Failed to determine an iOS runtime identifier from simctl." >&2 - "$SIMCTL" list runtimes >&2 || true + echo "error: no compatible iOS simulator runtime found for Xcode SDK ${ios_sim_sdk:-unknown}." >&2 + echo "hint: install an iOS Simulator runtime for ${ios_sim_sdk:-this SDK} from Xcode > Settings > Platforms" >&2 + ./tools/ios-runtime-doctor >&2 || true exit 1 fi @@ -62,7 +88,27 @@ if [ -z "${device_type_id:-}" ]; then fi device_name="Pika iPhone 15" -udid="$("$SIMCTL" list devices | awk -F '[()]' -v name="$device_name" '$0 ~ name {print $2; exit}')" +udid="$( + python3 - "$runtime_id" "$device_name" "$SIMCTL" <<'PY' +import json +import subprocess +import sys + +runtime_id = sys.argv[1] +target_name = sys.argv[2] +simctl = sys.argv[3] +j = json.loads(subprocess.check_output([simctl, "list", "-j", "devices"], text=True)) +for dev in j.get("devices", {}).get(runtime_id, []): + if dev.get("name") != target_name: + continue + if dev.get("isAvailable") is False: + continue + udid = dev.get("udid") or "" + if udid: + print(udid) + break +PY +)" if [ -z "${udid:-}" ]; then udid="$("$SIMCTL" create "$device_name" "$device_type_id" "$runtime_id" | tr -d '[:space:]')" echo "created simulator: $device_name ($udid)" diff --git a/tools/ios-typing-perf b/tools/ios-typing-perf new file mode 100755 index 000000000..be81eed0d --- /dev/null +++ b/tools/ios-typing-perf @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +chars="${PIKA_UI_TYPING_PERF_CHARS:-120}" +warmup="${PIKA_UI_TYPING_PERF_WARMUP:-10}" + +usage() { + cat <&2; exit 2; } + chars="$1" + ;; + --warmup) + shift + [ $# -gt 0 ] || { echo "error: --warmup requires a value" >&2; exit 2; } + warmup="$1" + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "error: unknown arg: $1" >&2 + usage >&2 + exit 2 + ;; + esac + shift +done + +if [ ! -f "ios/Pika.xcodeproj/project.pbxproj" ]; then + echo "error: ios/Pika.xcodeproj is missing. Run: just ios-xcframework ios-xcodeproj" >&2 + exit 1 +fi +if [ ! -d "ios/Frameworks/PikaCore.xcframework" ] || [ ! -d "ios/Frameworks/PikaNSE.xcframework" ]; then + echo "error: iOS xcframeworks are missing. Run: just ios-xcframework ios-xcodeproj" >&2 + exit 1 +fi + +udid="$(./tools/ios-sim-ensure | sed -n 's/^ok: ios simulator ready (udid=\(.*\))$/\1/p')" +if [ -z "$udid" ]; then + echo "error: could not determine simulator udid" >&2 + exit 1 +fi + +log_file="$(mktemp -t pika-ios-typing-perf.XXXXXX.log)" +cleanup() { + rm -f "$log_file" +} +trap cleanup EXIT + +if ! PIKA_UI_TYPING_PERF_CHARS="$chars" \ + PIKA_UI_TYPING_PERF_WARMUP="$warmup" \ + ./tools/xcode-run xcodebuild \ + -project ios/Pika.xcodeproj \ + -scheme Pika \ + -derivedDataPath ios/build \ + -destination "id=$udid" \ + test \ + ARCHS=arm64 \ + ONLY_ACTIVE_ARCH=YES \ + CODE_SIGNING_ALLOWED=NO \ + PIKA_APP_BUNDLE_ID="${PIKA_IOS_BUNDLE_ID:-org.pikachat.pika.dev}" \ + -only-testing:PikaUITests/TypingPerfUITests/testTypingLatencySummary \ + -only-testing:PikaUITests/TypingPerfUITests/testTypingBulkSummary \ + >"$log_file" 2>&1; then + echo "error: typing perf UI test failed; tailing xcodebuild output" >&2 + tail -n 120 "$log_file" >&2 + exit 1 +fi + +summary_line="$(rg 'PIKA_TYPING_PERF ' "$log_file" | tail -n 1 || true)" +if [ -z "$summary_line" ]; then + echo "error: perf summary not found in test output" >&2 + tail -n 120 "$log_file" >&2 + exit 1 +fi +bulk_line="$(rg 'PIKA_TYPING_BULK ' "$log_file" | tail -n 1 || true)" + +avg_ms="$(printf '%s\n' "$summary_line" | sed -n 's/.*avg_ms=\([0-9.]*\).*/\1/p')" +best_ms="$(printf '%s\n' "$summary_line" | sed -n 's/.*best_ms=\([0-9.]*\).*/\1/p')" +worst_ms="$(printf '%s\n' "$summary_line" | sed -n 's/.*worst_ms=\([0-9.]*\).*/\1/p')" +samples="$(printf '%s\n' "$summary_line" | sed -n 's/.*samples=\([0-9]*\).*/\1/p')" +bulk_total_ms="$(printf '%s\n' "$bulk_line" | sed -n 's/.*total_ms=\([0-9.]*\).*/\1/p')" +bulk_per_char_ms="$(printf '%s\n' "$bulk_line" | sed -n 's/.*per_char_ms=\([0-9.]*\).*/\1/p')" + +if [ -z "$avg_ms" ] || [ -z "$best_ms" ] || [ -z "$worst_ms" ] || [ -z "$samples" ]; then + echo "error: failed to parse perf summary" >&2 + echo "$summary_line" >&2 + exit 1 +fi + +if [ -n "$bulk_total_ms" ] && [ -n "$bulk_per_char_ms" ]; then + printf 'typing-keypress-ms avg=%s best=%s worst=%s samples=%s bulk_total=%s bulk_per_char=%s\n' \ + "$avg_ms" "$best_ms" "$worst_ms" "$samples" "$bulk_total_ms" "$bulk_per_char_ms" +else + printf 'typing-keypress-ms avg=%s best=%s worst=%s samples=%s\n' "$avg_ms" "$best_ms" "$worst_ms" "$samples" +fi diff --git a/tools/ios-typing-perf-keypress-profile b/tools/ios-typing-perf-keypress-profile new file mode 100755 index 000000000..cada634b4 --- /dev/null +++ b/tools/ios-typing-perf-keypress-profile @@ -0,0 +1,497 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +chars="${PIKA_UI_TYPING_PERF_CHARS:-120}" +warmup="${PIKA_UI_TYPING_PERF_WARMUP:-10}" +time_limit="${PIKA_UI_TYPING_PERF_TRACE_TIME_LIMIT:-2m}" +output_dir="${PIKA_UI_TYPING_PERF_TRACE_DIR:-artifacts/perf}" +top_n="${PIKA_UI_TYPING_PERF_TOP_STACKS:-15}" +main_thread_only=1 +open_trace=0 +input_trace="" + +usage() { + cat <&2; exit 2; } + chars="$1" + ;; + --warmup) + shift + [ $# -gt 0 ] || { echo "error: --warmup requires a value" >&2; exit 2; } + warmup="$1" + ;; + --time-limit) + shift + [ $# -gt 0 ] || { echo "error: --time-limit requires a value (e.g. 90s, 2m)" >&2; exit 2; } + time_limit="$1" + ;; + --top) + shift + [ $# -gt 0 ] || { echo "error: --top requires a value" >&2; exit 2; } + top_n="$1" + ;; + --all-threads) + main_thread_only=0 + ;; + --trace) + shift + [ $# -gt 0 ] || { echo "error: --trace requires a path" >&2; exit 2; } + input_trace="$1" + ;; + --open) + open_trace=1 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "error: unknown arg: $1" >&2 + usage >&2 + exit 2 + ;; + esac + shift +done + +if [ ! -x "./tools/ios-typing-perf" ]; then + echo "error: missing ./tools/ios-typing-perf" >&2 + exit 1 +fi + +mkdir -p "$output_dir" +stamp="$(date +%Y%m%d-%H%M%S)" +trace_path="${input_trace:-$output_dir/typing-keypress-profile-$stamp.trace}" +xctrace_log="" +tp_pid="" +perf_summary="" + +finish_trace() { + if [ -n "$tp_pid" ] && kill -0 "$tp_pid" 2>/dev/null; then + kill -INT "$tp_pid" 2>/dev/null || true + fi + if [ -n "$tp_pid" ]; then + wait "$tp_pid" || true + tp_pid="" + fi +} +if [ -n "$input_trace" ]; then + if [ ! -e "$trace_path" ]; then + echo "error: trace file not found: $trace_path" >&2 + exit 1 + fi +else + udid="$(./tools/ios-sim-ensure | sed -n 's/^ok: ios simulator ready (udid=\(.*\))$/\1/p')" + if [ -z "$udid" ]; then + echo "error: could not determine simulator udid" >&2 + exit 1 + fi + + xctrace_log="$(mktemp -t pika-ios-typing-profile-trace.XXXXXX.log)" + trap finish_trace EXIT + + ./tools/xcode-run xcrun xctrace record \ + --template "Time Profiler" \ + --device "$udid" \ + --all-processes \ + --output "$trace_path" \ + --time-limit "$time_limit" \ + --no-prompt \ + >"$xctrace_log" 2>&1 & + tp_pid="$!" + + # Give xctrace a chance to initialize so the first keypresses are included. + sleep 2 + + if ! perf_summary="$(PIKA_UI_TYPING_PERF_SIGNPOSTS=1 PIKA_UI_TYPING_PERF_CHARS="$chars" PIKA_UI_TYPING_PERF_WARMUP="$warmup" ./tools/ios-typing-perf)"; then + echo "error: typing perf tests failed" >&2 + tail -n 120 "$xctrace_log" >&2 || true + exit 1 + fi + + finish_trace + trap - EXIT + + if [ ! -e "$trace_path" ]; then + echo "error: expected trace file was not created: $trace_path" >&2 + tail -n 120 "$xctrace_log" >&2 || true + exit 1 + fi +fi + +python3 - "$ROOT" "$trace_path" "$output_dir" "$stamp" "$top_n" "$main_thread_only" <<'PY' +import bisect +import collections +import os +import re +import subprocess +import sys +import tempfile +import xml.etree.ElementTree as ET + +repo_root, trace_path, output_dir, stamp, top_n_raw, main_only_raw = sys.argv[1:7] +top_n = int(top_n_raw) +main_only = main_only_raw == "1" + +xcode_run = os.path.join(repo_root, "tools", "xcode-run") + +def run_export(xpath: str, out_path: str) -> None: + cmd = [ + xcode_run, + "xcrun", + "xctrace", + "export", + "--input", + trace_path, + "--xpath", + xpath, + "--output", + out_path, + ] + subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + +def parse_int(text: str | None) -> int | None: + if text is None: + return None + s = text.strip() + if not s: + return None + try: + return int(s, 0) + except ValueError: + return None + +def resolve_value(elem: ET.Element | None, value_by_id: dict[str, str], cast=None): + if elem is None: + return None + ref = elem.attrib.get("ref") + if ref: + raw = value_by_id.get(ref) + if raw is None: + return None + else: + raw = (elem.text or "").strip() or elem.attrib.get("fmt") + elem_id = elem.attrib.get("id") + if raw is not None and elem_id: + value_by_id[elem_id] = raw + if cast is None: + return raw + return cast(raw) + +def resolve_pid_from_process(elem: ET.Element | None, process_pid_by_id: dict[str, int], pid_value_by_id: dict[str, str]) -> int | None: + if elem is None: + return None + ref = elem.attrib.get("ref") + if ref: + return process_pid_by_id.get(ref) + pid_elem = elem.find("pid") + pid = resolve_value(pid_elem, pid_value_by_id, parse_int) + if pid is None: + return None + process_id = elem.attrib.get("id") + if process_id: + process_pid_by_id[process_id] = pid + return pid + +tmpdir = tempfile.mkdtemp(prefix="pika-typing-profile-") +process_xml = os.path.join(tmpdir, "process.xml") +thread_xml = os.path.join(tmpdir, "thread.xml") +roi_xml = os.path.join(tmpdir, "roi.xml") +time_xml = os.path.join(tmpdir, "time.xml") + +run_export('/trace-toc/run[@number="1"]/data/table[@schema="process-info"]', process_xml) +run_export('/trace-toc/run[@number="1"]/data/table[@schema="thread-info"]', thread_xml) +run_export('/trace-toc/run[@number="1"]/data/table[@schema="region-of-interest"]', roi_xml) +run_export('/trace-toc/run[@number="1"]/data/table[@schema="time-profile"]', time_xml) + +# Resolve process names -> pids. +string_by_id: dict[str, str] = {} +pid_value_by_id: dict[str, str] = {} +pid_to_name: dict[int, str] = {} +tree = ET.parse(process_xml) +for row in tree.findall(".//row"): + pid = resolve_value(row.find("pid"), pid_value_by_id, parse_int) + name = resolve_value(row.find("string"), string_by_id, lambda s: s) + if pid is not None and name: + pid_to_name[pid] = name + +pika_pids = sorted(pid for pid, name in pid_to_name.items() if name == "Pika") +runner_pids = sorted(pid for pid, name in pid_to_name.items() if name == "PikaUITests-Runner") +if not pika_pids: + print("error: could not resolve Pika pid from trace process-info", file=sys.stderr) + sys.exit(1) +pika_pid_set = set(pika_pids) +runner_pid_set = set(runner_pids) + +# Resolve main thread tids for all Pika pids. +pid_values: dict[str, str] = {} +tid_values: dict[str, str] = {} +bool_values: dict[str, str] = {} +main_tid_by_pid: dict[int, int] = {} +tree = ET.parse(thread_xml) +for row in tree.findall(".//row"): + pid = resolve_value(row.find("pid"), pid_values, parse_int) + tid = resolve_value(row.find("tid"), tid_values, parse_int) + main_raw = resolve_value(row.find("boolean"), bool_values, lambda s: s) + if pid in pika_pid_set and tid is not None and main_raw in {"1", "Yes", "yes", "true", "True"}: + main_tid_by_pid[pid] = tid + +if main_only and not main_tid_by_pid: + print("error: main thread tids for Pika were not found in trace", file=sys.stderr) + sys.exit(1) + +# Keypress intervals from signposts. +start_values: dict[str, str] = {} +duration_values: dict[str, str] = {} +signpost_values: dict[str, str] = {} +pid_values = {} +process_pid_by_id: dict[str, int] = {} +intervals: list[tuple[int, int]] = [] +tree = ET.parse(roi_xml) +for row in tree.findall(".//row"): + name = resolve_value(row.find("signpost-name"), signpost_values, lambda s: s) + if name != "composer_keypress": + continue + start = resolve_value(row.find("start-time"), start_values, parse_int) + duration = resolve_value(row.find("duration"), duration_values, parse_int) + pid = resolve_pid_from_process(row.find("process"), process_pid_by_id, pid_values) + if start is None or duration is None: + continue + if runner_pid_set and pid is not None and pid not in runner_pid_set: + continue + intervals.append((start, start + duration)) + +intervals.sort() +if not intervals: + print("error: no composer_keypress intervals were found in trace (signposts missing)", file=sys.stderr) + sys.exit(1) + +starts = [s for s, _ in intervals] +ends = [e for _, e in intervals] + +def in_interval(sample_ns: int) -> bool: + i = bisect.bisect_right(starts, sample_ns) - 1 + return i >= 0 and sample_ns <= ends[i] + +sample_values: dict[str, str] = {} +pid_values = {} +tid_values = {} +process_pid_by_id = {} +thread_tid_by_id: dict[str, int] = {} +thread_pid_by_id: dict[str, int] = {} +frame_name_by_id: dict[str, str] = {} +backtrace_frames_by_id: dict[str, list[str]] = {} + +collapsed_all = collections.Counter() +leaf_all = collections.Counter() +sample_count_all = 0 + +collapsed_main = collections.Counter() +leaf_main = collections.Counter() +sample_count_main = 0 + +pid_samples_all = collections.Counter() +pid_samples_main = collections.Counter() + +for event, elem in ET.iterparse(time_xml, events=("end",)): + if elem.tag != "row": + continue + + sample_elem = elem.find("sample-time") + sample_ns = resolve_value(sample_elem, sample_values, parse_int) + if sample_ns is None: + elem.clear() + continue + sample_in_interval = in_interval(sample_ns) + + process_elem = elem.find("process") + pid = resolve_pid_from_process(process_elem, process_pid_by_id, pid_values) + + thread_elem = elem.find("thread") + if thread_elem is None: + elem.clear() + continue + thread_ref = thread_elem.attrib.get("ref") + thread_pid = None + if thread_ref: + tid = thread_tid_by_id.get(thread_ref) + thread_pid = thread_pid_by_id.get(thread_ref) + else: + tid = resolve_value(thread_elem.find("tid"), tid_values, parse_int) + thread_pid = resolve_pid_from_process(thread_elem.find("process"), process_pid_by_id, pid_values) + if thread_pid is None: + fmt = thread_elem.attrib.get("fmt") or "" + match = re.search(r"pid:\s*(\d+)\)", fmt) + if match: + thread_pid = int(match.group(1)) + thread_id = thread_elem.attrib.get("id") + if thread_id: + if tid is not None: + thread_tid_by_id[thread_id] = tid + if thread_pid is not None: + thread_pid_by_id[thread_id] = thread_pid + + if pid is None: + pid = thread_pid + if not sample_in_interval: + elem.clear() + continue + if pid not in pika_pid_set: + elem.clear() + continue + + if tid is None: + elem.clear() + continue + + bt_elem = elem.find("backtrace") + if bt_elem is None: + elem.clear() + continue + + bt_ref = bt_elem.attrib.get("ref") + if bt_ref: + frames = backtrace_frames_by_id.get(bt_ref, []) + else: + frames: list[str] = [] + for frame_elem in bt_elem.findall("frame"): + frame_ref = frame_elem.attrib.get("ref") + if frame_ref: + name = frame_name_by_id.get(frame_ref) + else: + name = frame_elem.attrib.get("name") + frame_id = frame_elem.attrib.get("id") + if frame_id and name: + frame_name_by_id[frame_id] = name + if name: + frames.append(name) + bt_id = bt_elem.attrib.get("id") + if bt_id: + backtrace_frames_by_id[bt_id] = frames + + if not frames: + elem.clear() + continue + + sample_count_all += 1 + pid_samples_all[pid] += 1 + leaf_all[frames[0]] += 1 + collapsed_key = ";".join(f.replace(";", ":") for f in reversed(frames)) + collapsed_all[collapsed_key] += 1 + + expected_main_tid = main_tid_by_pid.get(pid) + if expected_main_tid is not None and tid == expected_main_tid: + sample_count_main += 1 + pid_samples_main[pid] += 1 + leaf_main[frames[0]] += 1 + collapsed_main[collapsed_key] += 1 + elem.clear() + +keypresses = len(intervals) +avg_samples_all = sample_count_all / max(1, keypresses) +avg_samples_main = sample_count_main / max(1, keypresses) + +scope = "all" +selected_collapsed = collapsed_all +selected_leaf = leaf_all +selected_sample_count = sample_count_all +selected_avg_samples = avg_samples_all + +if main_only: + scope = "main" + selected_collapsed = collapsed_main + selected_leaf = leaf_main + selected_sample_count = sample_count_main + selected_avg_samples = avg_samples_main + if selected_sample_count == 0 and sample_count_all > 0: + scope = "all-fallback" + selected_collapsed = collapsed_all + selected_leaf = leaf_all + selected_sample_count = sample_count_all + selected_avg_samples = avg_samples_all + +folded_path = os.path.join(output_dir, f"typing-keypress-profile-{stamp}.folded") +with open(folded_path, "w", encoding="utf-8") as f: + for stack, count in selected_collapsed.most_common(): + f.write(f"{stack} {count}\n") + +pid_list = ",".join(str(pid) for pid in pika_pids) +main_tid_list = ",".join(f"{pid}:{tid}" for pid, tid in sorted(main_tid_by_pid.items())) +print( + "typing-keypress-profile " + f"keypresses={keypresses} " + f"scope={scope} " + f"main_samples={sample_count_main} " + f"main_avg_samples_per_keypress={avg_samples_main:.3f} " + f"all_samples={sample_count_all} " + f"all_avg_samples_per_keypress={avg_samples_all:.3f} " + f"pika_pids={pid_list} " + f"main_tids={main_tid_list if main_tid_list else 'n/a'} " + f"mode={'main' if main_only else 'all'}" +) +for name, count in selected_leaf.most_common(top_n): + print( + "typing-keypress-top " + f"scope={scope} " + f"samples={count} " + f"avg_per_keypress={count / max(1, keypresses):.3f} " + f"pct={(100.0 * count / max(1, selected_sample_count)):.2f} " + f"frame={name}" + ) + +hex_leaf_samples = sum( + count + for name, count in selected_leaf.items() + if re.fullmatch(r"0x[0-9a-fA-F]+", name) +) +if selected_sample_count > 0 and (hex_leaf_samples / selected_sample_count) >= 0.80: + print( + "typing-keypress-warning " + "reason=unsymbolicated_frames " + f"hex_leaf_pct={(100.0 * hex_leaf_samples / selected_sample_count):.1f} " + "hint='All-process simulator traces can lose process image mapping. " + "For symbolized stacks, profile by attaching Time Profiler to the Pika process in Instruments.'" + ) +print(f"typing-keypress-folded path={folded_path}") +PY + +if [ -n "$perf_summary" ]; then + printf '%s\n' "$perf_summary" +fi +printf 'time-profiler-trace path=%s\n' "$trace_path" + +folded_path="$(ls -1 "$output_dir"/typing-keypress-profile-"$stamp".folded 2>/dev/null | tail -n 1 || true)" +if [ -n "$folded_path" ] && command -v inferno-flamegraph >/dev/null 2>&1; then + svg_path="${folded_path%.folded}.svg" + inferno-flamegraph <"$folded_path" >"$svg_path" + printf 'typing-keypress-flamegraph path=%s\n' "$svg_path" +fi + +if [ "$open_trace" = "1" ]; then + open "$trace_path" >/dev/null 2>&1 || true +fi + +if [ -n "$xctrace_log" ]; then + rm -f "$xctrace_log" +fi diff --git a/tools/ios-typing-perf-keypress-profile-symbolized b/tools/ios-typing-perf-keypress-profile-symbolized new file mode 100755 index 000000000..512f71f7a --- /dev/null +++ b/tools/ios-typing-perf-keypress-profile-symbolized @@ -0,0 +1,221 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +chars="${PIKA_UI_TYPING_PERF_CHARS:-120}" +warmup="${PIKA_UI_TYPING_PERF_WARMUP:-10}" +time_limit="${PIKA_UI_TYPING_PERF_TRACE_TIME_LIMIT:-2m}" +start_delay_ms="${PIKA_UI_TYPING_PERF_START_DELAY_MS:-4000}" +output_dir="${PIKA_UI_TYPING_PERF_TRACE_DIR:-artifacts/perf}" +top_n="${PIKA_UI_TYPING_PERF_TOP_STACKS:-15}" +main_thread_only=1 +open_trace=0 +bundle_id="${PIKA_IOS_BUNDLE_ID:-org.pikachat.pika.dev}" + +usage() { + cat <&2; exit 2; } + chars="$1" + ;; + --warmup) + shift + [ $# -gt 0 ] || { echo "error: --warmup requires a value" >&2; exit 2; } + warmup="$1" + ;; + --time-limit) + shift + [ $# -gt 0 ] || { echo "error: --time-limit requires a value (e.g. 90s, 2m)" >&2; exit 2; } + time_limit="$1" + ;; + --start-delay-ms) + shift + [ $# -gt 0 ] || { echo "error: --start-delay-ms requires a value" >&2; exit 2; } + start_delay_ms="$1" + ;; + --top) + shift + [ $# -gt 0 ] || { echo "error: --top requires a value" >&2; exit 2; } + top_n="$1" + ;; + --all-threads) + main_thread_only=0 + ;; + --open) + open_trace=1 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "error: unknown arg: $1" >&2 + usage >&2 + exit 2 + ;; + esac + shift +done + +if [ ! -f "ios/Pika.xcodeproj/project.pbxproj" ]; then + echo "error: ios/Pika.xcodeproj is missing. Run: just ios-xcframework ios-xcodeproj" >&2 + exit 1 +fi +if [ ! -d "ios/Frameworks/PikaCore.xcframework" ] || [ ! -d "ios/Frameworks/PikaNSE.xcframework" ]; then + echo "error: iOS xcframeworks are missing. Run: just ios-xcframework ios-xcodeproj" >&2 + exit 1 +fi + +udid="$(./tools/ios-sim-ensure | sed -n 's/^ok: ios simulator ready (udid=\(.*\))$/\1/p')" +if [ -z "$udid" ]; then + echo "error: could not determine simulator udid" >&2 + exit 1 +fi + +mkdir -p "$output_dir" +stamp="$(date +%Y%m%d-%H%M%S)" +trace_path="$output_dir/typing-keypress-profile-symbolized-$stamp.trace" +xcode_log="$(mktemp -t pika-ios-typing-symbolized-xcode.XXXXXX.log)" +xctrace_log="$(mktemp -t pika-ios-typing-symbolized-trace.XXXXXX.log)" +xcode_pid="" +tp_pid="" + +cleanup() { + if [ -n "$xcode_pid" ] && kill -0 "$xcode_pid" 2>/dev/null; then + kill "$xcode_pid" >/dev/null 2>&1 || true + fi + if [ -n "$tp_pid" ] && kill -0 "$tp_pid" 2>/dev/null; then + kill -INT "$tp_pid" >/dev/null 2>&1 || true + fi + if [ -n "$tp_pid" ]; then + wait "$tp_pid" >/dev/null 2>&1 || true + fi + if [ -n "$xcode_pid" ]; then + wait "$xcode_pid" >/dev/null 2>&1 || true + fi + rm -f "$xcode_log" "$xctrace_log" +} +trap cleanup EXIT + +PIKA_UI_TYPING_PERF_CHARS="$chars" \ +PIKA_UI_TYPING_PERF_WARMUP="$warmup" \ +PIKA_UI_TYPING_PERF_START_DELAY_MS="$start_delay_ms" \ +PIKA_UI_TYPING_PERF_SIGNPOSTS=0 \ +PIKA_UI_TYPING_PERF_APP_SIGNPOSTS=1 \ +./tools/xcode-run xcodebuild \ + -project ios/Pika.xcodeproj \ + -scheme Pika \ + -derivedDataPath ios/build \ + -destination "id=$udid" \ + test \ + ARCHS=arm64 \ + ONLY_ACTIVE_ARCH=YES \ + CODE_SIGNING_ALLOWED=NO \ + DEBUG_INFORMATION_FORMAT=dwarf-with-dsym \ + ENABLE_DEBUG_DYLIB=NO \ + PIKA_APP_BUNDLE_ID="$bundle_id" \ + -only-testing:PikaUITests/TypingPerfUITests/testTypingLatencySummary \ + >"$xcode_log" 2>&1 & +xcode_pid="$!" + +app_pid="" +deadline=$((SECONDS + 90)) +while [ "$SECONDS" -lt "$deadline" ]; do + app_pid="$( + ./tools/xcode-run xcrun simctl spawn "$udid" launchctl list 2>/dev/null \ + | awk -v bundle="$bundle_id" ' + $1 ~ /^[0-9]+$/ && index($3, "UIKitApplication:" bundle "[") == 1 { print $1; exit } + ' + )" + if [ -n "$app_pid" ]; then + break + fi + if ! kill -0 "$xcode_pid" 2>/dev/null; then + break + fi + sleep 0.2 +done + +if [ -z "$app_pid" ]; then + echo "error: failed to resolve app pid for bundle id: $bundle_id" >&2 + tail -n 120 "$xcode_log" >&2 || true + exit 1 +fi + +./tools/xcode-run xcrun xctrace record \ + --template "Time Profiler" \ + --device "$udid" \ + --attach "$app_pid" \ + --output "$trace_path" \ + --time-limit "$time_limit" \ + --no-prompt \ + >"$xctrace_log" 2>&1 & +tp_pid="$!" + +# Ensure tracer has initialized before the test starts measured keypresses. +sleep 1 + +if ! wait "$xcode_pid"; then + echo "error: typing perf UI test failed; tailing xcodebuild output" >&2 + tail -n 120 "$xcode_log" >&2 || true + tail -n 120 "$xctrace_log" >&2 || true + exit 1 +fi +xcode_pid="" + +if [ -n "$tp_pid" ] && kill -0 "$tp_pid" 2>/dev/null; then + kill -INT "$tp_pid" >/dev/null 2>&1 || true +fi +if [ -n "$tp_pid" ]; then + wait "$tp_pid" >/dev/null 2>&1 || true + tp_pid="" +fi + +if [ ! -e "$trace_path" ]; then + echo "error: expected trace file was not created: $trace_path" >&2 + tail -n 120 "$xctrace_log" >&2 || true + exit 1 +fi + +summary_line="$(rg 'PIKA_TYPING_PERF ' "$xcode_log" | tail -n 1 || true)" +if [ -z "$summary_line" ]; then + echo "error: perf summary not found in test output" >&2 + tail -n 120 "$xcode_log" >&2 || true + exit 1 +fi + +avg_ms="$(printf '%s\n' "$summary_line" | sed -n 's/.*avg_ms=\([0-9.]*\).*/\1/p')" +best_ms="$(printf '%s\n' "$summary_line" | sed -n 's/.*best_ms=\([0-9.]*\).*/\1/p')" +worst_ms="$(printf '%s\n' "$summary_line" | sed -n 's/.*worst_ms=\([0-9.]*\).*/\1/p')" +samples="$(printf '%s\n' "$summary_line" | sed -n 's/.*samples=\([0-9]*\).*/\1/p')" +if [ -z "$avg_ms" ] || [ -z "$best_ms" ] || [ -z "$worst_ms" ] || [ -z "$samples" ]; then + echo "error: failed to parse perf summary" >&2 + echo "$summary_line" >&2 + exit 1 +fi +printf 'typing-keypress-ms avg=%s best=%s worst=%s samples=%s\n' "$avg_ms" "$best_ms" "$worst_ms" "$samples" + +analyze_args=(--trace "$trace_path" --top "$top_n") +if [ "$main_thread_only" = "0" ]; then + analyze_args+=(--all-threads) +fi +if [ "$open_trace" = "1" ]; then + analyze_args+=(--open) +fi +./tools/ios-typing-perf-keypress-profile "${analyze_args[@]}" diff --git a/tools/ios-typing-perf-samply b/tools/ios-typing-perf-samply new file mode 100755 index 000000000..00cc38d0e --- /dev/null +++ b/tools/ios-typing-perf-samply @@ -0,0 +1,394 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +chars="${PIKA_UI_TYPING_PERF_CHARS:-120}" +warmup="${PIKA_UI_TYPING_PERF_WARMUP:-10}" +start_delay_ms="${PIKA_UI_TYPING_PERF_START_DELAY_MS:-8000}" +duration_s="${PIKA_UI_TYPING_PERF_SAMPLY_DURATION_S:-75}" +output_dir="${PIKA_UI_TYPING_PERF_TRACE_DIR:-artifacts/perf}" +bundle_id="${PIKA_IOS_BUNDLE_ID:-org.pikachat.pika.dev}" +attach_attempts="${PIKA_UI_TYPING_PERF_SAMPLY_ATTACH_ATTEMPTS:-6}" +attach_grace_s="${PIKA_UI_TYPING_PERF_SAMPLY_ATTACH_GRACE_S:-2}" +attach_initial_delay_s="${PIKA_UI_TYPING_PERF_SAMPLY_ATTACH_DELAY_S:-10}" +attach_wait_for_typing="${PIKA_UI_TYPING_PERF_SAMPLY_ATTACH_ON_TYPING:-0}" +attach_wait_timeout_s="${PIKA_UI_TYPING_PERF_SAMPLY_ATTACH_TIMEOUT_S:-120}" +fallback_to_xctrace="${PIKA_UI_TYPING_PERF_SAMPLY_FALLBACK_XCTRACE:-1}" +open_profile=0 + +usage() { + cat <&2; exit 2; } + chars="$1" + ;; + --warmup) + shift + [ $# -gt 0 ] || { echo "error: --warmup requires a value" >&2; exit 2; } + warmup="$1" + ;; + --start-delay-ms) + shift + [ $# -gt 0 ] || { echo "error: --start-delay-ms requires a value" >&2; exit 2; } + start_delay_ms="$1" + ;; + --duration-s) + shift + [ $# -gt 0 ] || { echo "error: --duration-s requires a value" >&2; exit 2; } + duration_s="$1" + ;; + --open) + open_profile=1 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "error: unknown arg: $1" >&2 + usage >&2 + exit 2 + ;; + esac + shift +done + +if ! command -v samply >/dev/null 2>&1; then + echo "error: samply not found in PATH." >&2 + echo "hint: enter the dev shell first: nix develop" >&2 + exit 1 +fi +samply_cmd="$(command -v samply)" +samply_from_nix=0 + +prepare_nix_samply_for_attach() { + local src_bin="$1" + local dst_bin="$HOME/.local/share/pika/bin/samply-nix-codesigned" + local ent_file + ent_file="$(mktemp -t pika-samply-entitlements.XXXXXX.plist)" + cat >"$ent_file" <<'EOF' + + + + com.apple.security.cs.debugger + com.apple.security.cs.disable-library-validation + +EOF + mkdir -p "$(dirname "$dst_bin")" + if [ ! -f "$dst_bin" ] || ! cmp -s "$src_bin" "$dst_bin"; then + cp "$src_bin" "$dst_bin" + chmod u+w "$dst_bin" + fi + if ! codesign --force --sign - --entitlements "$ent_file" "$dst_bin" >/dev/null 2>&1; then + rm -f "$ent_file" + echo "error: failed to codesign local samply binary for attach profiling." >&2 + echo "hint: verify codesign works locally and retry." >&2 + exit 1 + fi + rm -f "$ent_file" + samply_cmd="$dst_bin" +} + +if [ "$(uname -s)" = "Darwin" ] && [ "${samply_cmd#/nix/store/}" != "$samply_cmd" ]; then + samply_from_nix=1 + prepare_nix_samply_for_attach "$samply_cmd" +fi + +if [ ! -f "ios/Pika.xcodeproj/project.pbxproj" ]; then + echo "error: ios/Pika.xcodeproj is missing. Run: just ios-xcframework ios-xcodeproj" >&2 + exit 1 +fi +if [ ! -d "ios/Frameworks/PikaCore.xcframework" ] || [ ! -d "ios/Frameworks/PikaNSE.xcframework" ]; then + echo "error: iOS xcframeworks are missing. Run: just ios-xcframework ios-xcodeproj" >&2 + exit 1 +fi + +udid="$(./tools/ios-sim-ensure | sed -n 's/^ok: ios simulator ready (udid=\(.*\))$/\1/p')" +if [ -z "$udid" ]; then + echo "error: could not determine simulator udid" >&2 + exit 1 +fi + +mkdir -p "$output_dir" +stamp="$(date +%Y%m%d-%H%M%S)" +profile_path="$output_dir/typing-keypress-samply-$stamp.json.gz" +xcode_log="$output_dir/typing-keypress-samply-$stamp-xcode.log" +samply_log="$output_dir/typing-keypress-samply-$stamp-samply.log" +samply_log_prefix="$output_dir/typing-keypress-samply-$stamp-samply-attempt" +xcode_pid="" +samply_pid="" +app_pid="" +samply_attempt_log_active="" + +cleanup() { + if [ -n "$xcode_pid" ] && kill -0 "$xcode_pid" 2>/dev/null; then + kill "$xcode_pid" >/dev/null 2>&1 || true + fi + if [ -n "$samply_pid" ] && kill -0 "$samply_pid" 2>/dev/null; then + kill -INT "$samply_pid" >/dev/null 2>&1 || true + fi + if [ -n "$samply_pid" ]; then + wait "$samply_pid" >/dev/null 2>&1 || true + fi + if [ -n "$xcode_pid" ]; then + wait "$xcode_pid" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +resolve_app_pid() { + ./tools/xcode-run xcrun simctl spawn "$udid" launchctl list 2>/dev/null \ + | awk -v bundle="$bundle_id" ' + $1 ~ /^[0-9]+$/ && index($3, "UIKitApplication:" bundle "[") == 1 { print $1; exit } + ' +} + +wait_for_typing_log() { + local deadline=$((SECONDS + attach_wait_timeout_s)) + while [ "$SECONDS" -lt "$deadline" ]; do + if [ ! -f "$xcode_log" ]; then + sleep 0.2 + continue + fi + if rg -q 'Type .* into "chat_message_input"' "$xcode_log"; then + return 0 + fi + if ! kill -0 "$xcode_pid" 2>/dev/null; then + return 1 + fi + sleep 0.2 + done + return 1 +} + +attempt_samply_attach() { + local attempt candidate_pid attempt_log deadline + deadline=$((SECONDS + attach_wait_timeout_s)) + attempt=0 + : >"$samply_log" + printf 'attach-config attempts=%s timeout_s=%s grace_s=%s\n' "$attach_attempts" "$attach_wait_timeout_s" "$attach_grace_s" >>"$samply_log" + while [ "$SECONDS" -lt "$deadline" ]; do + if ! kill -0 "$xcode_pid" 2>/dev/null; then + printf 'attach-stop reason=xcode-exited attempts=%s\n' "$attempt" >>"$samply_log" + return 1 + fi + + candidate_pid="$(resolve_app_pid)" + if [ -z "$candidate_pid" ]; then + sleep 0.3 + continue + fi + + attempt=$((attempt + 1)) + attempt_log="${samply_log_prefix}${attempt}.log" + samply_attempt_log_active="$attempt_log" + rm -f "$attempt_log" + printf 'attach-attempt n=%s pid=%s\n' "$attempt" "$candidate_pid" >>"$samply_log" + "$samply_cmd" record \ + -p "$candidate_pid" \ + -d "$duration_s" \ + --save-only \ + --no-open \ + -o "$profile_path" \ + >"$attempt_log" 2>&1 & + samply_pid="$!" + + sleep "$attach_grace_s" + if kill -0 "$samply_pid" 2>/dev/null; then + app_pid="$candidate_pid" + printf 'attach-attempt n=%s status=running\n' "$attempt" >>"$samply_log" + cat "$attempt_log" >>"$samply_log" + return 0 + fi + + wait "$samply_pid" >/dev/null 2>&1 || true + samply_pid="" + cat "$attempt_log" >>"$samply_log" + + if rg -q "task_for_pid.*failed|samply setup|Could not obtain the root task|Could not check process libraries|InvalidAddress" "$attempt_log"; then + printf 'attach-attempt n=%s status=retryable-failure\n' "$attempt" >>"$samply_log" + if [ "$attempt" -ge "$attach_attempts" ]; then + printf 'attach-stop reason=attempt-limit-reached attempts=%s\n' "$attempt" >>"$samply_log" + return 1 + fi + sleep 0.4 + continue + fi + printf 'attach-stop reason=non-retryable-failure attempts=%s\n' "$attempt" >>"$samply_log" + return 1 + done + printf 'attach-stop reason=timeout attempts=%s\n' "$attempt" >>"$samply_log" + return 1 +} + +run_xctrace_fallback() { + local fallback_args=( + --chars "$chars" + --warmup "$warmup" + --start-delay-ms "$start_delay_ms" + --time-limit "${duration_s}s" + ) + if [ "$open_profile" = "1" ]; then + fallback_args+=(--open) + fi + echo "warning: falling back to symbolized Time Profiler capture due samply attach instability." >&2 + ./tools/ios-typing-perf-keypress-profile-symbolized "${fallback_args[@]}" +} + +PIKA_UI_TYPING_PERF_CHARS="$chars" \ +PIKA_UI_TYPING_PERF_WARMUP="$warmup" \ +PIKA_UI_TYPING_PERF_START_DELAY_MS="$start_delay_ms" \ +PIKA_UI_TYPING_PERF_APP_SIGNPOSTS=1 \ +./tools/xcode-run xcodebuild \ + -project ios/Pika.xcodeproj \ + -scheme Pika \ + -derivedDataPath ios/build \ + -destination "id=$udid" \ + test \ + ARCHS=arm64 \ + ONLY_ACTIVE_ARCH=YES \ + CODE_SIGNING_ALLOWED=NO \ + DEBUG_INFORMATION_FORMAT=dwarf-with-dsym \ + ENABLE_DEBUG_DYLIB=NO \ + PIKA_APP_BUNDLE_ID="$bundle_id" \ + -only-testing:PikaUITests/TypingPerfUITests/testTypingLatencySummary \ + >"$xcode_log" 2>&1 & +xcode_pid="$!" + +if [ "$attach_initial_delay_s" != "0" ]; then + deadline=$((SECONDS + attach_initial_delay_s)) + while [ "$SECONDS" -lt "$deadline" ]; do + if ! kill -0 "$xcode_pid" 2>/dev/null; then + break + fi + sleep 0.2 + done +fi + +if [ "$attach_wait_for_typing" = "1" ]; then + if ! wait_for_typing_log; then + echo "warning: typing marker not observed before attach timeout; continuing with pid attach attempts" >&2 + fi +fi + +if ! attempt_samply_attach; then + if [ "$fallback_to_xctrace" = "1" ]; then + run_xctrace_fallback + exit $? + fi + echo "error: samply could not attach after $attach_attempts attempt(s)." >&2 + tail -n 120 "$samply_log" >&2 || true + tail -n 120 "$xcode_log" >&2 || true + exit 1 +fi + +retryable_pattern="task_for_pid.*failed|samply setup|Could not obtain the root task|Could not check process libraries|InvalidAddress" +while kill -0 "$xcode_pid" 2>/dev/null; do + if [ -n "$samply_pid" ] && [ -n "$samply_attempt_log_active" ] && rg -q "$retryable_pattern" "$samply_attempt_log_active"; then + printf 'attach-watchdog reason=retryable-panic pid=%s log=%s\n' "$app_pid" "$samply_attempt_log_active" >>"$samply_log" + kill -INT "$samply_pid" >/dev/null 2>&1 || true + wait "$samply_pid" >/dev/null 2>&1 || true + samply_pid="" + if ! attempt_samply_attach; then + if [ "$fallback_to_xctrace" = "1" ]; then + run_xctrace_fallback + exit $? + fi + echo "error: samply could not recover from attach panic while test was running." >&2 + tail -n 120 "$samply_log" >&2 || true + tail -n 120 "$xcode_log" >&2 || true + exit 1 + fi + fi + sleep 0.4 +done + +if ! wait "$xcode_pid"; then + echo "error: typing perf UI test failed; tailing xcodebuild output" >&2 + tail -n 120 "$xcode_log" >&2 || true + exit 1 +fi +xcode_pid="" + +if [ -n "$samply_pid" ] && kill -0 "$samply_pid" 2>/dev/null; then + kill -INT "$samply_pid" >/dev/null 2>&1 || true +fi +if [ -n "$samply_pid" ]; then + if ! wait "$samply_pid"; then + if rg -q "$retryable_pattern" "$samply_log"; then + if [ "$fallback_to_xctrace" = "1" ]; then + run_xctrace_fallback + exit $? + fi + echo "error: samply attach/recording failed for pid $app_pid." >&2 + if [ "$samply_from_nix" = "1" ]; then + echo "hint: nix-store samply cannot be attach-profiled directly on macOS; use the auto-codesigned local copy path." >&2 + else + echo "hint: run this once, then retry: samply setup" >&2 + fi + echo "hint: retry with more attempts: PIKA_UI_TYPING_PERF_SAMPLY_ATTACH_ATTEMPTS=10 just ios-typing-perf-samply" >&2 + tail -n 80 "$samply_log" >&2 || true + exit 1 + fi + echo "error: samply recording failed." >&2 + tail -n 80 "$samply_log" >&2 || true + exit 1 + fi + samply_pid="" +fi + +if [ ! -s "$profile_path" ]; then + echo "error: samply profile was not created: $profile_path" >&2 + tail -n 80 "$samply_log" >&2 || true + exit 1 +fi + +summary_line="$(rg 'PIKA_TYPING_PERF ' "$xcode_log" | tail -n 1 || true)" +if [ -z "$summary_line" ]; then + echo "error: perf summary not found in test output" >&2 + tail -n 120 "$xcode_log" >&2 || true + exit 1 +fi + +avg_ms="$(printf '%s\n' "$summary_line" | sed -n 's/.*avg_ms=\([0-9.]*\).*/\1/p')" +best_ms="$(printf '%s\n' "$summary_line" | sed -n 's/.*best_ms=\([0-9.]*\).*/\1/p')" +worst_ms="$(printf '%s\n' "$summary_line" | sed -n 's/.*worst_ms=\([0-9.]*\).*/\1/p')" +samples="$(printf '%s\n' "$summary_line" | sed -n 's/.*samples=\([0-9]*\).*/\1/p')" + +if [ -z "$avg_ms" ] || [ -z "$best_ms" ] || [ -z "$worst_ms" ] || [ -z "$samples" ]; then + echo "error: failed to parse perf summary" >&2 + echo "$summary_line" >&2 + exit 1 +fi + +printf 'typing-keypress-ms avg=%s best=%s worst=%s samples=%s\n' "$avg_ms" "$best_ms" "$worst_ms" "$samples" +printf 'samply-profile path=%s\n' "$profile_path" +printf 'samply-log path=%s\n' "$samply_log" +printf 'xcode-log path=%s\n' "$xcode_log" + +if [ "$open_profile" = "1" ]; then + "$samply_cmd" load "$profile_path" >/dev/null 2>&1 || true +fi diff --git a/tools/ios-typing-perf-trace b/tools/ios-typing-perf-trace new file mode 100755 index 000000000..e9b82ed0a --- /dev/null +++ b/tools/ios-typing-perf-trace @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +chars="${PIKA_UI_TYPING_PERF_CHARS:-120}" +warmup="${PIKA_UI_TYPING_PERF_WARMUP:-10}" +time_limit="${PIKA_UI_TYPING_PERF_TRACE_TIME_LIMIT:-2m}" +output_dir="${PIKA_UI_TYPING_PERF_TRACE_DIR:-artifacts/perf}" +open_trace=1 + +usage() { + cat <&2; exit 2; } + chars="$1" + ;; + --warmup) + shift + [ $# -gt 0 ] || { echo "error: --warmup requires a value" >&2; exit 2; } + warmup="$1" + ;; + --time-limit) + shift + [ $# -gt 0 ] || { echo "error: --time-limit requires a value (e.g. 90s, 2m)" >&2; exit 2; } + time_limit="$1" + ;; + --output-dir) + shift + [ $# -gt 0 ] || { echo "error: --output-dir requires a value" >&2; exit 2; } + output_dir="$1" + ;; + --no-open) + open_trace=0 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "error: unknown arg: $1" >&2 + usage >&2 + exit 2 + ;; + esac + shift +done + +if [ ! -x "./tools/ios-typing-perf" ]; then + echo "error: missing ./tools/ios-typing-perf" >&2 + exit 1 +fi + +udid="$(./tools/ios-sim-ensure | sed -n 's/^ok: ios simulator ready (udid=\(.*\))$/\1/p')" +if [ -z "$udid" ]; then + echo "error: could not determine simulator udid" >&2 + exit 1 +fi + +mkdir -p "$output_dir" +trace_path="$output_dir/typing-$(date +%Y%m%d-%H%M%S).trace" +xctrace_log="$(mktemp -t pika-ios-typing-trace.XXXXXX.log)" +tp_pid="" + +finish_trace() { + if [ -n "$tp_pid" ] && kill -0 "$tp_pid" 2>/dev/null; then + kill -INT "$tp_pid" 2>/dev/null || true + fi + if [ -n "$tp_pid" ]; then + wait "$tp_pid" || true + tp_pid="" + fi +} +trap finish_trace EXIT + +./tools/xcode-run xcrun xctrace record \ + --template "Time Profiler" \ + --device "$udid" \ + --all-processes \ + --output "$trace_path" \ + --time-limit "$time_limit" \ + --no-prompt \ + >"$xctrace_log" 2>&1 & +tp_pid="$!" + +# Give xctrace a moment to start before typing begins. +sleep 2 + +if ! summary_line="$(PIKA_UI_TYPING_PERF_CHARS="$chars" PIKA_UI_TYPING_PERF_WARMUP="$warmup" ./tools/ios-typing-perf)"; then + echo "error: typing perf summary run failed" >&2 + tail -n 120 "$xctrace_log" >&2 || true + exit 1 +fi + +finish_trace +trap - EXIT + +if [ ! -e "$trace_path" ]; then + echo "error: expected trace file was not created: $trace_path" >&2 + tail -n 120 "$xctrace_log" >&2 || true + exit 1 +fi + +printf '%s\n' "$summary_line" +printf 'time-profiler-trace path=%s\n' "$trace_path" + +if [ "$open_trace" = "1" ]; then + open "$trace_path" >/dev/null 2>&1 || true +fi + +rm -f "$xctrace_log" diff --git a/tools/xcode-dev-dir b/tools/xcode-dev-dir index bfdb94280..90098a28c 100755 --- a/tools/xcode-dev-dir +++ b/tools/xcode-dev-dir @@ -1,10 +1,14 @@ #!/usr/bin/env bash set -euo pipefail -# Resolve an Xcode Developer directory with deterministic precedence: -# 1) Respect DEVELOPER_DIR if already set (flake shell pins this). -# 2) Respect xcode-select (xcode-wrapper may pin this in nix shells). -# 3) Fallback to latest /Applications/Xcode*.app for non-nix usage. +# Resolve an Xcode Developer directory with deterministic precedence while +# preferring a toolchain that has a compatible iOS simulator runtime: +# 1) DEVELOPER_DIR +# 2) xcode-select +# 3) latest /Applications/Xcode*.app +# +# If no candidate has a compatible simulator runtime, fall back to the first +# valid candidate so non-simulator workflows can still run. is_valid_dev_dir() { local dir="${1:-}" @@ -13,20 +17,78 @@ is_valid_dev_dir() { && [ -x "$dir/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang" ] } -if is_valid_dev_dir "${DEVELOPER_DIR:-}"; then - echo "$DEVELOPER_DIR" - exit 0 -fi +has_compatible_ios_sim_runtime() { + local dir="${1:-}" + local simctl="$dir/usr/bin/simctl" + local sdk="" + + sdk="$(DEVELOPER_DIR="$dir" /usr/bin/xcrun --sdk iphonesimulator --show-sdk-version 2>/dev/null || true)" + [ -n "$sdk" ] || return 1 + + python3 - "$simctl" "$sdk" <<'PY' +import json +import re +import subprocess +import sys + +simctl = sys.argv[1] +sdk = sys.argv[2] +m = re.match(r"^(\d+)(?:\.(\d+))?", sdk) +if not m: + sys.exit(1) +sdk_major = int(m.group(1)) +sdk_minor = int(m.group(2) or 0) + +try: + j = json.loads(subprocess.check_output([simctl, "list", "-j", "runtimes"], text=True)) +except Exception: + sys.exit(1) + +for rt in j.get("runtimes", []): + name = rt.get("name") or "" + ident = rt.get("identifier") or "" + if not name.startswith("iOS "): + continue + if rt.get("isAvailable") is False: + continue + rm = re.search(r"iOS-(\d+)-(\d+)$", ident) + if not rm: + continue + major = int(rm.group(1)) + minor = int(rm.group(2)) + if major == sdk_major and minor >= sdk_minor: + sys.exit(0) +sys.exit(1) +PY +} + +first_valid="" + +try_candidate() { + local dir="${1:-}" + if ! is_valid_dev_dir "$dir"; then + return 1 + fi + if [ -z "$first_valid" ]; then + first_valid="$dir" + fi + if has_compatible_ios_sim_runtime "$dir"; then + echo "$dir" + exit 0 + fi + return 1 +} + +try_candidate "${DEVELOPER_DIR:-}" || true SELECTED_DIR="$(xcode-select -p 2>/dev/null || true)" -if is_valid_dev_dir "$SELECTED_DIR"; then - echo "$SELECTED_DIR" - exit 0 -fi +try_candidate "$SELECTED_DIR" || true LATEST_DIR="$(ls -d /Applications/Xcode*.app/Contents/Developer 2>/dev/null | sort -V | tail -n 1 || true)" -if is_valid_dev_dir "$LATEST_DIR"; then - echo "$LATEST_DIR" +try_candidate "$LATEST_DIR" || true + +if [ -n "$first_valid" ]; then + echo "$first_valid" exit 0 fi