mirror of
https://github.com/SpartanJ/eepp.git
synced 2026-10-18 03:23:28 +03:00
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.
329 lines
11 KiB
Python
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()
|