Files
eepp/projects/scripts/benchmark_atspi.py
T
Martín Lucas Golini d27eae5927 Harden accessibility lifecycle, events and native backends
Teardown and identity:
- Detach native backends before scene children are destroyed and clear
  mRoot afterwards, fixing a use-after-free when an AT-SPI client is
  connected while a window closes.
- Invalidate a deleted widget's subtree once, top-down, before OnClose
  handlers run (NODE_FLAG_DESTROYING). Descendants never walk into
  destroyed parents, and deleted identities cannot be re-registered.
- Closing scenes expose no accessibility manager through either accessor.
- Deleting widgets no client has seen allocates no identity and emits no
  events.
- Ignore events from widgets not yet attached to a scene.

Events:
- Keep the order of state and structural events; coalesce only adjacent
  query-again hints.
- Stop emitting per-widget BoundsChanged; bounds are queried on demand.
- Report the Focused state and keyboard focus only on the projected focus
  owner, through a shared helper used by both paths.

Model views:
- Lazy child count and child queries, cached sibling indexes, selection
  and hit testing without enumerating rows.
- Row identities follow inserted and removed rows; removed rows are
  pruned from native wrapper caches.
- Expose the active cell editor as the child of the row or cell it edits.

AT-SPI:
- Correct state bits (EXPANDABLE/EXPANDED, SENSITIVE, CHECKABLE,
  SELECTABLE, READ_ONLY, SINGLE/MULTI_LINE, SELECTABLE_TEXT) and the
  password-text and panel roles.
- Emit text-changed insert/delete, caret-moved and selection-changed
  events, diffed once per frame. Support line granularity,
  SetCaretOffset and selection editing.
- Track clients by bus name so the active flag clears on disconnect.
- Fix the I/O thread busy-spin and stranded dispatch backlog, flush
  queued signals, and type-check Properties.Set values.

UIA:
- Raise focus only for the focused element.
- Defer UiaDisconnectProvider out of widget destruction.
- Keep providers alive for in-flight cross-thread requests.
- Reject unknown interfaces without a UI-thread round trip.
- Require window-specific client activity.

macOS:
- Avoid repeated full node info builds.
- Use sibling indexes and lazy child access.
- Fix label/value deduplication for list items.

Tests and docs:
- Add regression tests for focus ownership, teardown, event ordering,
  lazy model identities, nested scenes and cell editors.
- Extend the AT-SPI validation scripts and document the current
  performance characteristics and limitations.
2026-10-09 00:06:36 -03:00

329 lines
11 KiB
Python

#!/usr/bin/env python3
import argparse
import json
import statistics
import subprocess
import threading
import time
import gi
gi.require_version("Atspi", "2.0")
from gi.repository import Atspi
from test_atspi import pause
def check(condition, message):
if not condition:
raise RuntimeError(message)
class Measurements:
def __init__(self):
self.calls = []
def call(self, name, function):
start = time.perf_counter()
result = function()
self.calls.append((name, (time.perf_counter() - start) * 1000))
return result
def summary(self):
durations = sorted(duration for _, duration in self.calls)
if not durations:
return {"calls": 0, "median_ms": 0, "p95_ms": 0, "maximum_ms": 0}
p95_index = min(len(durations) - 1, int(len(durations) * 0.95))
return {
"calls": len(durations),
"median_ms": statistics.median(durations),
"p95_ms": durations[p95_index],
"maximum_ms": durations[-1],
}
def find_application(name, process_id, timeout):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
desktop = Atspi.get_desktop(0)
for index in range(desktop.get_child_count() - 1, -1, -1):
application = desktop.get_child_at_index(index)
try:
if (
application
and application.get_name() == name
and application.get_process_id() == process_id
):
return application
except Exception:
continue
pause(0.01)
raise RuntimeError(f'accessibility application "{name}" was not discovered')
def traverse(root, measurements):
nodes = []
pending = [root]
while pending:
node = pending.pop()
nodes.append(node)
measurements.call("name", node.get_name)
measurements.call("role", node.get_role_name)
count = measurements.call("child-count", node.get_child_count)
for index in range(count - 1, -1, -1):
child = measurements.call(
"child-at-index", lambda node=node, index=index: node.get_child_at_index(index)
)
if child:
pending.append(child)
return nodes
def find_named(nodes, name):
for node in nodes:
if node.get_name() == name:
return node
raise RuntimeError(f'accessibility node "{name}" was not found')
def action_index(node, name):
for index in range(node.get_n_actions()):
if Atspi.Action.get_localized_name(node, index) == name:
return index
raise RuntimeError(f'action "{name}" was not exposed by "{node.get_name()}"')
def run_sample(args, item_count, target_process_id, model_changed, model_change_count):
process = None
try:
start = time.perf_counter()
process = subprocess.Popen(
[args.executable, "--benchmark", "--benchmark-items", str(item_count)],
stdout=subprocess.DEVNULL,
)
target_process_id[0] = process.pid
application = find_application(args.application, process.pid, args.timeout)
discovery_ms = (time.perf_counter() - start) * 1000
measurements = Measurements()
start = time.perf_counter()
nodes = traverse(application, measurements)
traversal_ms = (time.perf_counter() - start) * 1000
check(len(nodes) >= item_count + 50, f"benchmark tree is incomplete: {len(nodes)} nodes")
find_named(nodes, "Benchmark depth leaf")
benchmark_list = find_named(nodes, "Benchmark items")
check(
benchmark_list.get_child_count() == item_count,
f"benchmark list must expose {item_count} items",
)
start = time.perf_counter()
for node in nodes[: min(args.sample_size, len(nodes))]:
measurements.call("description", node.get_description)
measurements.call("state", node.get_state_set)
measurements.call("attributes", node.get_attributes)
measurements.call("extents", lambda node=node: node.get_extents(Atspi.CoordType.SCREEN))
property_batch_ms = (time.perf_counter() - start) * 1000
project_name = find_named(nodes, "Project name")
check(
measurements.call("text-character-count", project_name.get_character_count) == 4,
"text character count query failed",
)
check(
measurements.call(
"text-content", lambda: Atspi.Text.get_text(project_name, 0, -1)
) == "eepp",
"text content query failed",
)
check(
measurements.call(
"editable-text", lambda: Atspi.EditableText.set_text_contents(project_name, "eepp2")
),
"editable text action failed",
)
check(
measurements.call(
"focus", lambda: project_name.do_action(action_index(project_name, "focus"))
),
"focus action failed",
)
checkbox = find_named(nodes, "Enable autosave")
check(
measurements.call(
"toggle", lambda: checkbox.do_action(action_index(checkbox, "toggle"))
),
"toggle action failed",
)
check(
measurements.call("toggle-state", checkbox.get_state_set).contains(
Atspi.StateType.CHECKED
),
"toggle state query failed",
)
spin = find_named(nodes, "Retry count")
check(measurements.call("range-value", spin.get_current_value) == 3, "range value query failed")
check(
measurements.call(
"set-range-value", lambda: Atspi.Value.set_current_value(spin, 4)
),
"range value action failed",
)
window = measurements.call("application-window", lambda: application.get_child_at_index(0))
window_extents = measurements.call(
"window-extents", lambda: window.get_extents(Atspi.CoordType.SCREEN)
)
measurements.call(
"component-hit-test",
lambda: window.get_accessible_at_point(
window_extents.x + window_extents.width // 2,
window_extents.y + window_extents.height // 2,
Atspi.CoordType.SCREEN,
),
)
mutate = find_named(nodes, "Replace benchmark model")
model_changed.clear()
model_change_count[0] = 0
start = time.perf_counter()
check(
mutate.do_action(action_index(mutate, "click")),
"benchmark model replacement action failed",
)
deadline = time.monotonic() + args.timeout
while not model_changed.is_set() and time.monotonic() < deadline:
pause(0.001)
check(model_changed.is_set(), "model replacement event was not received")
while benchmark_list.get_child_count() != item_count and time.monotonic() - start < args.timeout:
pause(0.001)
mutation_ms = (time.perf_counter() - start) * 1000
first_item = benchmark_list.get_child_at_index(0)
check(first_item.get_name() == "Updated benchmark item 0", "model replacement stayed stale")
check(model_change_count[0] == 1, "model-change burst was not coalesced")
return {
"nodes": len(nodes),
"item_count": item_count,
"discovery_ms": discovery_ms,
"traversal_ms": traversal_ms,
"property_batch_ms": property_batch_ms,
"mutation_ms": mutation_ms,
"model_change_events": model_change_count[0],
**measurements.summary(),
}
finally:
target_process_id[0] = None
if process:
process.terminate()
try:
process.wait(timeout=1)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
def median_result(samples):
result = {"item_count": samples[0]["item_count"], "iterations": len(samples)}
for key in samples[0]:
if key not in {"item_count", "model_change_events"}:
result[key] = statistics.median(sample[key] for sample in samples)
result["model_change_events"] = max(sample["model_change_events"] for sample in samples)
result["traversal_ms_per_1k"] = result["traversal_ms"] * 1000 / result["nodes"]
result["mutation_ms_per_1k"] = result["mutation_ms"] * 1000 / result["item_count"]
return result
def parse_item_counts(value):
counts = sorted(set(int(count) for count in value.split(",")))
check(counts and counts[0] > 0, "item counts must be positive")
return counts
def main():
parser = argparse.ArgumentParser(description="Benchmark eepp's AT-SPI query path")
parser.add_argument("--application", default="eepp - Accessibility")
parser.add_argument("--executable", default="bin/eepp-ui-accessibility")
parser.add_argument("--timeout", type=float, default=5.0)
parser.add_argument("--sample-size", type=int, default=100)
parser.add_argument("--item-counts", default="250,1000,4000")
parser.add_argument("--iterations", type=int, default=3)
parser.add_argument("--max-discovery-ms", type=float, default=2000)
parser.add_argument("--max-traversal-ms-per-1k", type=float, default=2000)
parser.add_argument("--max-property-batch-ms", type=float, default=500)
parser.add_argument("--max-mutation-ms-per-1k", type=float, default=1000)
parser.add_argument("--max-request-ms", type=float, default=250)
parser.add_argument("--max-p95-ms", type=float, default=5)
parser.add_argument("--max-scaling-ratio", type=float, default=2.5)
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
check(args.iterations > 0, "iterations must be positive")
item_counts = parse_item_counts(args.item_counts)
model_changed = threading.Event()
model_change_count = [0]
target_process_id = [None]
def on_model_changed(event):
try:
if event.source.get_process_id() == target_process_id[0]:
model_change_count[0] += 1
model_changed.set()
except Exception:
pass
listener = Atspi.EventListener.new(on_model_changed)
check(listener.register("object:model-changed"), "could not register model-change listener")
try:
results = []
for item_count in item_counts:
samples = [
run_sample(
args, item_count, target_process_id, model_changed, model_change_count
)
for _ in range(args.iterations)
]
results.append(median_result(samples))
for result in results:
check(result["discovery_ms"] <= args.max_discovery_ms, "application discovery exceeded threshold")
check(
result["traversal_ms_per_1k"] <= args.max_traversal_ms_per_1k,
"normalized tree traversal exceeded threshold",
)
check(result["property_batch_ms"] <= args.max_property_batch_ms, "property batch exceeded threshold")
check(
result["mutation_ms_per_1k"] <= args.max_mutation_ms_per_1k,
"normalized model mutation exceeded threshold",
)
check(result["maximum_ms"] <= args.max_request_ms, "an individual accessibility request exceeded threshold")
check(result["p95_ms"] <= args.max_p95_ms, "p95 accessibility request latency exceeded threshold")
baseline_cost = results[0]["traversal_ms_per_1k"]
scaling_ratio = max(
result["traversal_ms_per_1k"] / baseline_cost for result in results[1:]
) if len(results) > 1 else 1
check(scaling_ratio <= args.max_scaling_ratio, "tree traversal scaling became nonlinear")
output = {"worst_scaling_ratio": scaling_ratio, "samples": results}
if args.json:
print(json.dumps(output, sort_keys=True))
else:
for result in results:
print(
f"{result['item_count']} items: {result['nodes']:.0f} nodes, "
f"traversal {result['traversal_ms']:.1f}ms "
f"({result['traversal_ms_per_1k']:.1f}ms/1k), "
f"p95 {result['p95_ms']:.3f}ms, maximum {result['maximum_ms']:.3f}ms"
)
print(f"AT-SPI benchmark passed: worst normalized growth {scaling_ratio:.2f}x")
finally:
listener.deregister("object:model-changed")
if __name__ == "__main__":
main()