cmux/tests/test_omnibar_focus_cpu.py
Lawrence Chen 5fc4df467a
Fix omnibar Cmd+L infinite focus loop causing 100% CPU (#97)
The browser omnibar's updateNSView and controlTextDidEndEditing
were both dispatching makeFirstResponder calls without any guard
against re-dispatch. Each makeFirstResponder triggers SwiftUI's
FirstResponderObserver, which re-evaluates the view graph, which
calls updateNSView again, creating an infinite loop via the main
dispatch queue.

Fix: Add a pendingFocusRequest flag on the coordinator to prevent
re-dispatching while a focus/blur request is already in flight.
Also add nsView.currentEditor() != nil to the isFirstResponder
check so the field is recognized as focused during the transition
when the field editor (not the field itself) is first responder.
2026-02-18 23:18:08 -08:00

186 lines
5.8 KiB
Python

#!/usr/bin/env python3
"""
Regression test: pressing Cmd+L to focus the browser omnibar must not cause
a CPU spike from an infinite makeFirstResponder loop.
Background: commit 2d64ecfc wrapped the omnibar's makeFirstResponder call in
DispatchQueue.main.async without a re-dispatch guard. Each async
makeFirstResponder triggers SwiftUI's FirstResponderObserver → view graph
re-evaluation → updateNSView → another async makeFirstResponder → ∞ loop,
pegging the main thread at 100% CPU.
This test opens a browser panel, triggers Cmd+L, and asserts that CPU stays
below threshold for a few seconds afterward.
Requires:
- cmux running (debug build)
"""
import os
import subprocess
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cmux import cmux, cmuxError
MAX_CPU_PERCENT = 30.0
SETTLE_AFTER_FOCUS_S = 1.5
MONITOR_DURATION_S = 3.0
SAMPLE_INTERVAL_S = 0.5
def get_cmux_pid() -> int | None:
socket_path = os.environ.get("CMUX_SOCKET_PATH")
if not socket_path:
try:
socket_path = cmux().socket_path
except Exception:
socket_path = None
if socket_path and os.path.exists(socket_path):
result = subprocess.run(
["lsof", "-t", socket_path],
capture_output=True, text=True,
)
if result.returncode == 0:
for line in result.stdout.strip().split("\n"):
line = line.strip()
if not line:
continue
try:
pid = int(line)
except ValueError:
continue
if pid != os.getpid():
return pid
result = subprocess.run(
["pgrep", "-f", r"cmux\.app/Contents/MacOS/cmux$"],
capture_output=True, text=True,
)
if result.returncode != 0:
result = subprocess.run(
["pgrep", "-f", r"cmux DEV\.app/Contents/MacOS/cmux"],
capture_output=True, text=True,
)
if result.returncode != 0:
return None
pids = result.stdout.strip().split("\n")
return int(pids[0]) if pids and pids[0] else None
def get_cpu(pid: int) -> float:
result = subprocess.run(
["ps", "-p", str(pid), "-o", "%cpu="],
capture_output=True, text=True,
)
if result.returncode != 0:
return 0.0
try:
return float(result.stdout.strip())
except ValueError:
return 0.0
def monitor_cpu(pid: int, duration: float, interval: float) -> list[float]:
readings: list[float] = []
start = time.time()
while time.time() - start < duration:
readings.append(get_cpu(pid))
time.sleep(interval)
return readings
def main() -> int:
print("=" * 60)
print("Omnibar Cmd+L Focus CPU Regression Test")
print("=" * 60)
pid = get_cmux_pid()
if pid is None:
print("\nSKIP: cmux is not running")
return 0
client = cmux()
client.connect()
try:
# Create a workspace with a browser panel.
ws_id = client.new_workspace()
client.select_workspace(ws_id)
time.sleep(0.5)
browser_id = client.new_surface(panel_type="browser", url="https://example.com")
time.sleep(3.0) # let page load and panel stabilize
# Focus the browser webview first.
client.focus_surface_by_panel(browser_id)
time.sleep(0.3)
client.focus_webview(browser_id)
time.sleep(0.5)
# Baseline CPU reading.
baseline = get_cpu(pid)
print(f"\nBaseline CPU: {baseline:.1f}%")
# Trigger Cmd+L to focus the omnibar.
print("Simulating Cmd+L...")
client.simulate_shortcut("cmd+l")
time.sleep(SETTLE_AFTER_FOCUS_S)
# Monitor CPU after Cmd+L.
print(f"Monitoring CPU for {MONITOR_DURATION_S}s...")
readings = monitor_cpu(pid, MONITOR_DURATION_S, SAMPLE_INTERVAL_S)
avg_cpu = sum(readings) / len(readings) if readings else 0
max_cpu = max(readings) if readings else 0
print(f"\nPost Cmd+L CPU:")
print(f" Average: {avg_cpu:.1f}%")
print(f" Max: {max_cpu:.1f}%")
print(f" Samples: {readings}")
# Test: repeat Cmd+L while already focused (should also be safe).
print("\nSimulating Cmd+L again (already focused)...")
client.simulate_shortcut("cmd+l")
time.sleep(SETTLE_AFTER_FOCUS_S)
readings2 = monitor_cpu(pid, MONITOR_DURATION_S, SAMPLE_INTERVAL_S)
avg_cpu2 = sum(readings2) / len(readings2) if readings2 else 0
max_cpu2 = max(readings2) if readings2 else 0
print(f" Average: {avg_cpu2:.1f}%")
print(f" Max: {max_cpu2:.1f}%")
# Verdict.
worst = max(max_cpu, max_cpu2)
if worst > MAX_CPU_PERCENT:
print(f"\nFAIL: CPU peaked at {worst:.1f}% (threshold {MAX_CPU_PERCENT}%)")
print("Likely infinite makeFirstResponder loop in omnibar updateNSView.")
# Take a diagnostic sample.
sample = subprocess.run(
["sample", str(pid), "2"],
capture_output=True, text=True,
)
sample_text = sample.stdout + sample.stderr
if "updateNSView" in sample_text or "makeFirstResponder" in sample_text:
print(" Confirmed: sample shows updateNSView / makeFirstResponder loop")
sample_path = f"/tmp/cmux_omnibar_focus_cpu_{pid}.txt"
with open(sample_path, "w") as f:
f.write(sample_text)
print(f" Sample saved to {sample_path}")
return 1
print(f"\nPASS: CPU stayed within bounds (peak {worst:.1f}%)")
return 0
finally:
# Cleanup: close the test workspace.
try:
client.close_workspace(ws_id)
except Exception:
pass
client.close()
if __name__ == "__main__":
sys.exit(main())