Skip to content

v0.3.15

Choose a tag to compare

@github-actions github-actions released this 03 Aug 02:20
· 69 commits to main since this release

Desktop state capture is roughly 10× faster, and far more than that on complex pages — with the captured node set unchanged.

Highlights

workload 0.3.14 0.3.15
browser window + system UI 695.7 ms ~69 ms
heavy DOM page (7,174 elements walked) 18.0 s 0.83 s

The win scales with tree size, so the pages that hurt most improve most. An 18-second capture becoming 0.83 s is the difference between unusable and interactive.

Nothing was traded away for it. Node sets were compared entry by entry — role, name and bounding box — on both workloads: 0 lost, 0 gained.

What was actually wrong

Three AXValue parsers probed a PyObjC bridge object with hasattr/getattr before falling back to the correct unboxing call:

if hasattr(pos_val, "x") and hasattr(pos_val, "y"):   # ~178 µs on an AXValueRef
    return (pos_val.x, pos_val.y)
try:
    success, point = AXValueGetValue(...)              # ~0.8 µs — the right answer

On an AXValueRef a missing attribute raises and unwinds through the bridge, so the probe costs ~178 µs while AXValueGetValue costs ~0.82 µs — about 200× cheaper. The expensive test was guarding the cheap answer, twice per element.

Profiling attributed 0.274 s of a 0.722 s capture to builtins.hasattr alone. Fixing the ordering in _parse_ax_position, _parse_ax_size and ParseCFRange accounted for two thirds of the total improvement.

Changed

  • AXValue parsers unbox before probing. As above — the single largest factor.
  • Per-value type classification is memoised. GetMultipleAttributeValues ran isinstance against a six-type tuple for every returned value: ~3.6 µs on a bridge type, roughly 8,000 times per capture. The answer depends only on the concrete type, and PyObjC returns very few here. Semantics are identical, including bool being an int subclass.
  • Traversal no longer descends into every collapsed container. The zero-area rule added in 0.3.14 stopped a degenerate box from pruning its subtree — which fixed real node loss, but walked every collapsed wrapper on a page and was expensive on DOM-heavy sites. It now descends only when the parent had a usable box, which is the overlay pattern that needed rescuing, and not when a collapsed wrapper sits inside another. Elements walked dropped from 738 to 409 with no node lost, recovering the capture-time regression 0.3.14 introduced while keeping the elements it was added to rescue.
  • Children arrays are materialised once rather than re-crossed over the PyObjC bridge by each len(), reversed() and index.

Notes

The screenshot path (use_vision=True) is unchanged in this release.

Two optimisations were measured and rejected rather than shipped: splitting the attribute fetch into two tiers (the per-call base cost dominates, so it loses on arithmetic), and prefetching attribute batches on a worker pool (measurably slower — a single application's accessibility server appears to serialise requests, so overlapping within one application buys nothing).

Full Changelog: v0.3.14...v0.3.15