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
2 changes: 2 additions & 0 deletions .github/workflows/cmux-next.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1276,6 +1276,8 @@ jobs:
bash tests/test_strip_release_bundle.sh
# The pre-push gate refuses a push that adds a violation and names the commit in CI.
python3 tests/test_cmux_next_prepush_gate.py
# The perf gate skips its per-frame timings on a window that draws no frames (--frames auto).
python3 tests/test_cmux_next_perf_gate_unlit.py
# A base merge pushed while the PR's CI runs gets a warning (scripts/ci/base_merge_warning.py).
python3 tests/test_base_merge_warning.py
# A changed tree, workflow file or toolchain never reuses a fast-tier pass (CI_NEXT_JOB_REUSE).
Expand Down
19 changes: 14 additions & 5 deletions scripts/cmux-next/perf-gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,10 @@
status 4 background workspaces print OSC 7501 progress 4 times a second for 5 s:
0 applies, 0 topology sends, 0 pane snapshots, 0 sidebar list reloads (I1-I3).

The drag lag and the theme blend need a window that draws. On a console without a lit display
(fleet minis, CI VMs) the window is occluded and the display link does not tick: with
--frames auto (the CI default) the script then reports those two checks as skipped (a warning);
The drag lag, the theme blend and the tab switch and new workspace timings (the app times them
per frame) need a window that draws. On a console without a lit display (fleet minis, CI VMs)
the window is occluded and the display link does not tick: with
--frames auto (the CI default) the script then reports those checks as skipped (a warning);
with --frames required (the GUI host) no frames is a failure. A budget breach names its callers
(debug.layout_counters `sources`).

Expand Down Expand Up @@ -407,6 +408,14 @@ def frames_ok(self, scenario):
warn(what + "; the frame-dependent checks are skipped here and run on the GUI host (--frames required)", scenario)
return False

def timed_count(self, rows, expected, what, scenario):
"""Whether the app timed every one of `expected` (`what`, e.g. "debug.tab_switch timed {n} of 40
switches"). The app times them against the display link's frames: a window that draws none
times none, which is the frame probe's skip (or its failure with --frames required)."""
if not rows and not self.frames_ok(scenario):
return False
return check(len(rows) == expected, what.format(n=len(rows)), scenario)

def switch_steps(self, targets, scenario):
"""goToWorkspace each target; per switch the counters must fit BUDGET['switch']."""
app, worst, patches, rows = self.app, {}, 0, []
Expand Down Expand Up @@ -714,7 +723,7 @@ def timed(call):
# first frame it could); the commit's end (an upper bound) is reported.
switches = own.get("switches") or []
phase["switches"] = [{k: round(v, 2) for k, v in row.items()} for row in switches]
if not check(len(switches) == ops, f"debug.tab_switch timed {len(switches)} of {ops} switches", scenario):
if not self.timed_count(switches, ops, f"debug.tab_switch timed {{n}} of {ops} switches", scenario):
return
col = lambda name: sorted(row[name] for row in switches)
p99 = lambda values: values[max(0, int(len(values) * 0.99) - 1)]
Expand Down Expand Up @@ -755,7 +764,7 @@ def new_workspaces(self, scenario="new_workspaces"):
wait(lambda: len(app.workspace_ids()) <= len(before), 60)
rows = [{k: round(v, 2) for k, v in row.items()} for row in own.get("creates") or []]
report["phases"][scenario] = {"workspaces_before": counts, "reply_ms": replies, "creates": rows}
if not check(len(rows) == creates, f"debug.new_workspace timed {len(rows)} of {creates} new workspace rows", scenario):
if not self.timed_count(rows, creates, f"debug.new_workspace timed {{n}} of {creates} new workspace rows", scenario):
return
for row, count in zip(rows, counts):
row["workspaces_before"] = count
Expand Down
4 changes: 4 additions & 0 deletions tests/test-execution.toml
Original file line number Diff line number Diff line change
Expand Up @@ -1016,6 +1016,10 @@ lane = "linux-guard"
path = "tests/test_cmux_next_prepush_gate.py"
lane = "linux-guard"

[[test]]
path = "tests/test_cmux_next_perf_gate_unlit.py"
lane = "linux-guard"

[[test]]
path = "tests/test_app_store_rules.py"
lane = "linux-guard"
Expand Down
88 changes: 88 additions & 0 deletions tests/test_cmux_next_perf_gate_unlit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""The perf gate's timed scenarios on a window that draws no frames (scripts/cmux-next/perf-gate.py).

debug.tab_switch and debug.new_workspace time each switch or row against the display link's
frames, so an occluded or unlit console (the PR scheme lane's runner) times none. With
--frames auto that is the frame probe's skip (a warning) like every other frame check, not
"timed 0 of 40" failures; with --frames required (the GUI host) it still fails, and a window
that draws but times nothing still fails.
"""
import importlib.util
import itertools
import os
import sys
import types
import unittest

ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
spec = importlib.util.spec_from_file_location("perf_gate", os.path.join(ROOT, "scripts/cmux-next/perf-gate.py"))
perf_gate = importlib.util.module_from_spec(spec)
sys.path.insert(0, os.path.join(ROOT, "scripts/cmux-next"))
spec.loader.exec_module(perf_gate)


class FakeApp:
"""The automation calls the tabs and new_workspaces scenarios make; the timers record nothing."""

def __init__(self, lit):
self.lit, self.workspaces, self.tabs = lit, ["w1"], itertools.cycle(["t1", "t2", "t3"])

def draws(self):
return (self.lit, {"frames": 60 if self.lit else 8, "refresh_ms": 16.7 if self.lit else 0})

def rpc(self, method, params=None, timeout=None):
if method == "action.run" and (params or {}).get("action") == "newTab":
self.workspaces.append(f"w{len(self.workspaces) + 1}")
return {}

def action(self, name, args=None, target=None):
if name == "closeWorkspace":
self.workspaces.remove(args["workspace"])

def workspace_ids(self):
return list(self.workspaces)

def topology(self):
return {"focus": {"tab": next(self.tabs)}}

def quiet_counters(self, during=None):
if during:
during()
return {**{key: 0 for key in perf_gate.BUDGET["tabs"]}, "sources": {}}

def settle(self):
pass


class UnlitWindowTests(unittest.TestCase):
def setUp(self):
del perf_gate.failures[:], perf_gate.warnings[:]
perf_gate.report.update({"checks": [], "phases": {}})

def gate(self, lit, frames="auto"):
gate = perf_gate.Gate(FakeApp(lit), types.SimpleNamespace(frames=frames, only=None))
gate.grid = (16, 48)
return gate

def run_timed(self, gate):
gate.tabs()
gate.new_workspaces()

def test_an_unlit_window_skips_the_timed_counts_with_a_warning(self):
self.run_timed(self.gate(lit=False))
self.assertEqual(perf_gate.failures, [])
self.assertTrue(any("draws no frames" in w for w in perf_gate.warnings), perf_gate.warnings)

def test_an_unlit_window_fails_when_frames_are_required(self):
self.run_timed(self.gate(lit=False, frames="required"))
self.assertTrue(perf_gate.failures)
self.assertTrue(all("draws no frames" in f for f in perf_gate.failures), perf_gate.failures)

def test_a_lit_window_that_times_nothing_fails(self):
self.run_timed(self.gate(lit=True))
self.assertIn("[tabs] debug.tab_switch timed 0 of 40 switches", perf_gate.failures)
self.assertIn("[new_workspaces] debug.new_workspace timed 0 of 50 new workspace rows", perf_gate.failures)


if __name__ == "__main__":
unittest.main()