diff --git a/.github/workflows/swift-ci.yml b/.github/workflows/swift-ci.yml index 1924e8a1..3483eb9a 100644 --- a/.github/workflows/swift-ci.yml +++ b/.github/workflows/swift-ci.yml @@ -227,7 +227,17 @@ jobs: - name: Premium performance budget shell: bash - run: scripts/ops/performance-budget.rb --check-home-recent-captures --allow-missing-parakeet-model --max-app-mb 220 --max-resources-mb 80 + # The app-build step above runs build.sh, whose launch smoke writes + # build/launch-ui-smoke.json with launchToInteractiveMs. Score it here so + # a launch-to-interactive regression fails CI (PRD WS4.2). The real UX + # number is the warm Apple-Silicon product target (<400ms, measured + # ~285ms). This CI gate is a COARSE catastrophic-regression guard only: + # the GitHub runner's single cold-launch sample is very noisy (observed + # 1420ms and 2095ms on identical commits, ~48% swing), so 3000ms is set + # to never flake while still failing on a gross blow-up (e.g. a + # synchronous model/network load added to the launch path). A future + # tightening is min-of-N sampling in the smoke to sharpen the signal. + run: scripts/ops/performance-budget.rb --check-home-recent-captures --allow-missing-parakeet-model --max-app-mb 220 --max-resources-mb 80 --launch-ui-smoke build/launch-ui-smoke.json --max-launch-interactive-ms 3000 # --------------------------------------------------------------------------- # Required-status umbrella. Branch protection requires the "build-and-test" diff --git a/Sources/TranscriptedApp.swift b/Sources/TranscriptedApp.swift index eceb86b1..30cc6392 100644 --- a/Sources/TranscriptedApp.swift +++ b/Sources/TranscriptedApp.swift @@ -891,6 +891,7 @@ class TranscriptedAppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegat statusItemExists: statusItem != nil, popoverConfigured: popover != nil, onboardingCompleted: true, + launchToInteractiveMs: Self.processStartToNowMilliseconds(), menuVisibilityOverride: smokeMenuVisibility ) let reportURL = URL(fileURLWithPath: reportPath, isDirectory: false) @@ -908,6 +909,30 @@ class TranscriptedAppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegat } } + /// Wall-clock milliseconds from the kernel's process-start time to now. + /// Measured from `kinfo_proc.p_starttime` so it captures the true + /// launch-to-interactive latency including pre-`main` dyld/runtime setup, + /// not just the time since the app delegate began running. Returns nil if + /// the syscall fails or the clock reads backwards. CI-smoke only. + private static func processStartToNowMilliseconds() -> Double? { + var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()] + var info = kinfo_proc() + var size = MemoryLayout.stride + let status = mib.withUnsafeMutableBufferPointer { buffer in + sysctl(buffer.baseAddress, UInt32(buffer.count), &info, &size, nil, 0) + } + guard status == 0 else { return nil } + + var now = timeval() + guard gettimeofday(&now, nil) == 0 else { return nil } + + let start = info.kp_proc.p_starttime + let startMs = Double(start.tv_sec) * 1000.0 + Double(start.tv_usec) / 1000.0 + let nowMs = Double(now.tv_sec) * 1000.0 + Double(now.tv_usec) / 1000.0 + let elapsed = nowMs - startMs + return elapsed >= 0 ? elapsed : nil + } + private func scheduleLaunchUISmokeTerminationIfRequested(environment: [String: String]) { guard environment["TRANSCRIPTED_LAUNCH_UI_SMOKE_TERMINATE_AFTER_REPORT"] == "1" else { return } diff --git a/Sources/UI/MenuBar/MenuBarPanelController.swift b/Sources/UI/MenuBar/MenuBarPanelController.swift index d5e6d641..27547334 100644 --- a/Sources/UI/MenuBar/MenuBarPanelController.swift +++ b/Sources/UI/MenuBar/MenuBarPanelController.swift @@ -10,6 +10,11 @@ struct MenuBarLaunchUISmokeReport: Codable, Equatable { let popoverConfigured: Bool let onboardingCompleted: Bool let content: MenuBarContentSmokeSnapshot + /// Wall-clock from kernel process-start to the moment the menu-bar UI is + /// interactive (status item + popover configured). Powers the CI + /// launch-to-interactive perf budget (PRD WS4.2). Optional so older report + /// consumers and hand-built fixtures stay decodable. + var launchToInteractiveMs: Double? } @MainActor @@ -158,6 +163,7 @@ final class MenuBarPanelController: NSViewController { statusItemExists: Bool, popoverConfigured: Bool, onboardingCompleted: Bool, + launchToInteractiveMs: Double? = nil, menuVisibilityOverride: [MenuBarOptionalItem: Bool] ) -> MenuBarLaunchUISmokeReport { loadViewIfNeeded() @@ -184,7 +190,8 @@ final class MenuBarPanelController: NSViewController { ), primaryActions: [:], utilityActions: [:] - ) + ), + launchToInteractiveMs: launchToInteractiveMs ) } diff --git a/Sources/UI/Overlay/OverlayRootView.swift b/Sources/UI/Overlay/OverlayRootView.swift index 2fe7a467..51d7da1f 100644 --- a/Sources/UI/Overlay/OverlayRootView.swift +++ b/Sources/UI/Overlay/OverlayRootView.swift @@ -115,10 +115,16 @@ final class OverlayRootView: NSView { // MARK: - State Updates // @_optimize(none): the -O SimplifyCFG pass crashes on this function's - // parameter/closure shape under swiftlang-6.3.2.1.108 (SIL/BorrowUtils.swift:542, - // "cannot get borrow introducers for unknown guaranteed value"). This is a - // compiler bug, not a correctness issue — disable optimization here until upstream - // fixes it. Reproduced in CI at github.com/r3dbars/transcripted actions run 28685559441. + // parameter/closure shape (SIL/BorrowUtils.swift:542, "cannot get borrow + // introducers for unknown guaranteed value"). This is a compiler bug, not a + // correctness issue — disable optimization here until upstream fixes it. + // First seen under swiftlang-6.3.2.1.108 (CI actions run 28685559441). + // Re-verified STILL CRASHING under swiftlang-6.3.3.1.3 (Swift 6.3.3) on + // 2026-07-07: removing this attribute reproduces the identical SimplifyCFG + // fault. Keep it. Perf cost is nil: `updateForState` runs only on discrete + // overlay state transitions (see FloatingOverlayController.pushStateToViews), + // never per audio frame — the hot meter path calls headerView.updateWaveformLevel + // directly, so this function is not on any hot path. @_optimize(none) func updateForState( _ state: FloatingOverlayController.OverlayState, diff --git a/scripts/entrypoints/build.sh b/scripts/entrypoints/build.sh index 61e0030c..cef2e66a 100755 --- a/scripts/entrypoints/build.sh +++ b/scripts/entrypoints/build.sh @@ -617,6 +617,13 @@ PERFORMANCE_BUDGET_ARGS=(--app "$APP_BUNDLE") if [ "$BUNDLE_PARAKEET_MODELS" = "0" ]; then PERFORMANCE_BUDGET_ARGS+=(--allow-missing-parakeet-model --max-app-mb 220 --max-resources-mb 80) fi +# The launch smoke (above) writes the interactive-readiness report; score its +# launch-to-interactive latency when it ran. Skipped smoke → no report → skip +# the budget rather than fail on a file that was intentionally not produced. +LAUNCH_UI_SMOKE_REPORT="$REPO_ROOT/$BUILD_DIR/launch-ui-smoke.json" +if [ "${TRANSCRIPTED_SKIP_LAUNCH_SMOKE:-0}" != "1" ] && [ -s "$LAUNCH_UI_SMOKE_REPORT" ]; then + PERFORMANCE_BUDGET_ARGS+=(--launch-ui-smoke "$LAUNCH_UI_SMOKE_REPORT" --max-launch-interactive-ms 3000) +fi scripts/ops/performance-budget.rb "${PERFORMANCE_BUDGET_ARGS[@]}" echo "Build complete!" diff --git a/scripts/ops/performance-budget.rb b/scripts/ops/performance-budget.rb index 636ebb06..db27c908 100755 --- a/scripts/ops/performance-budget.rb +++ b/scripts/ops/performance-budget.rb @@ -24,6 +24,12 @@ MAX_MEETING_P95_RTF = 0.05 MAX_HOME_RECENT_CAPTURE_AVERAGE_LOAD_MS = 750.0 MAX_HOME_RECENT_CAPTURE_CANCEL_MS = 100.0 +# Launch-to-interactive ceiling (kernel process-start -> menu-bar UI ready), +# read from the launch UI smoke report. PRD WS4.2 product target is <400ms warm +# on Apple Silicon; this regression ceiling carries headroom for slower/cold CI +# runners while still catching a multi-x blow-up. Callers pass an explicit +# --max-launch-interactive-ms tuned to the surface (dev build vs CI runner). +MAX_LAUNCH_TO_INTERACTIVE_MS = 1_500.0 MIN_MEETING_DURATION_SECONDS = 30.0 MIN_TRANSCRIPTION_SAMPLES = 10 MIN_STARTUP_SAMPLES = 3 @@ -60,6 +66,8 @@ check_home_recent_captures: false, max_home_recent_capture_average_load_ms: MAX_HOME_RECENT_CAPTURE_AVERAGE_LOAD_MS, max_home_recent_capture_cancel_ms: MAX_HOME_RECENT_CAPTURE_CANCEL_MS, + launch_ui_smoke_path: nil, + max_launch_to_interactive_ms: MAX_LAUNCH_TO_INTERACTIVE_MS, require_launch_model_ready_samples: 0, require_dictation_fast_start_samples: 0, require_dictation_stop_latency_samples: 0, @@ -90,6 +98,8 @@ parser.on("--check-home-recent-captures", "Run deterministic Home recent-captures loader budget") { options[:check_home_recent_captures] = true } parser.on("--max-home-recent-capture-average-load-ms MS", Float, "Home recent-captures average load budget") { |ms| options[:max_home_recent_capture_average_load_ms] = ms } parser.on("--max-home-recent-capture-cancel-ms MS", Float, "Home recent-captures cancellation acknowledgement budget") { |ms| options[:max_home_recent_capture_cancel_ms] = ms } + parser.on("--launch-ui-smoke PATH", "Launch UI smoke report JSON for the launch-to-interactive budget") { |path| options[:launch_ui_smoke_path] = path } + parser.on("--max-launch-interactive-ms MS", Float, "Launch-to-interactive budget in ms (kernel process-start to menu-bar UI ready)") { |ms| options[:max_launch_to_interactive_ms] = ms } parser.on("--require-launch-model-ready-samples N", Integer, "Require at least N launch-to-model-ready samples in --events logs") { |count| options[:require_launch_model_ready_samples] = count } parser.on("--require-dictation-fast-start-samples N", Integer, "Require at least N fast-start samples in --events logs") { |count| options[:require_dictation_fast_start_samples] = count } parser.on("--require-dictation-stop-latency-samples N", Integer, "Require at least N stop-latency samples in --events logs") { |count| options[:require_dictation_stop_latency_samples] = count } @@ -477,6 +487,30 @@ def run_home_recent_capture_benchmark(max_average_load_ms:, max_cancel_ms:) end end +launch_interactive_summary = nil +if options[:launch_ui_smoke_path] + smoke_path = Pathname.new(options[:launch_ui_smoke_path]).expand_path + if !smoke_path.file? + errors << "Missing launch UI smoke report: #{smoke_path}" + else + begin + report = JSON.parse(File.read(smoke_path)) + value = float_or_nil(report["launchToInteractiveMs"]) + budget = options[:max_launch_to_interactive_ms] + if value.nil? + errors << "Launch UI smoke report #{smoke_path} is missing launchToInteractiveMs" + else + launch_interactive_summary = { value_ms: value, budget_ms: budget } + if value > budget + errors << "Launch to interactive is #{format("%.1fms", value)}, above #{format("%.1fms", budget)}" + end + end + rescue JSON::ParserError => error + errors << "Launch UI smoke report #{smoke_path} is not valid JSON: #{error.message}" + end + end +end + fail_budget!(errors) puts "Performance budget OK" @@ -555,3 +589,7 @@ def run_home_recent_capture_benchmark(max_average_load_ms:, max_cancel_ms:) puts "Home recent-captures benchmark:" puts home_recent_capture_summary[:stdout].strip end +if launch_interactive_summary + puts "Launch to interactive: #{format("%.1fms", launch_interactive_summary[:value_ms])} " \ + "(budget #{format("%.1fms", launch_interactive_summary[:budget_ms])})" +end