Bug 256761 - AX: Avoid hitting the main thread in NSApplicationAccessibilityFocusedUIElement in isolated tree mode.
Summary: AX: Avoid hitting the main thread in NSApplicationAccessibilityFocusedUIEleme...
Status: RESOLVED FIXED
Alias: None
Product: WebKit
Classification: Unclassified
Component: Accessibility (show other bugs)
Version: WebKit Nightly Build
Hardware: All All
: P2 Normal
Assignee: Andres Gonzalez
URL:
Keywords: InRadar
Depends on:
Blocks:
 
Reported: 2023-05-13 12:09 PDT by Andres Gonzalez
Modified: 2023-06-08 07:33 PDT (History)
11 users (show)

See Also:


Attachments
Patch (8.82 KB, patch)
2023-05-13 12:32 PDT, Andres Gonzalez
no flags Details | Formatted Diff | Diff
Patch (13.37 KB, patch)
2023-05-16 13:37 PDT, Andres Gonzalez
no flags Details | Formatted Diff | Diff
Patch (15.19 KB, patch)
2023-06-01 18:19 PDT, Andres Gonzalez
no flags Details | Formatted Diff | Diff
Patch (15.33 KB, patch)
2023-06-01 18:44 PDT, Andres Gonzalez
no flags Details | Formatted Diff | Diff
Patch (15.35 KB, patch)
2023-06-02 10:22 PDT, Andres Gonzalez
no flags Details | Formatted Diff | Diff
Patch (15.36 KB, patch)
2023-06-05 08:11 PDT, Andres Gonzalez
no flags Details | Formatted Diff | Diff
Patch (15.02 KB, patch)
2023-06-06 14:09 PDT, Andres Gonzalez
no flags Details | Formatted Diff | Diff
Patch (15.04 KB, patch)
2023-06-06 15:02 PDT, Andres Gonzalez
no flags Details | Formatted Diff | Diff
Patch (15.05 KB, patch)
2023-06-07 11:38 PDT, Andres Gonzalez
no flags Details | Formatted Diff | Diff
Patch (15.05 KB, patch)
2023-06-07 17:27 PDT, Andres Gonzalez
no flags Details | Formatted Diff | Diff
Patch (14.95 KB, patch)
2023-06-08 06:07 PDT, Andres Gonzalez
no flags Details | Formatted Diff | Diff

Note You need to log in before you can comment on or make changes to this bug.
Description Andres Gonzalez 2023-05-13 12:09:58 PDT
NSApplicationAccessibilityFocusedUIElement is frequently called by AT to track the focus object. Currently it has to hit the main thread every time.
Comment 1 Radar WebKit Bug Importer 2023-05-13 12:10:11 PDT
<rdar://problem/109307200>
Comment 2 Andres Gonzalez 2023-05-13 12:32:17 PDT
Created attachment 466345 [details]
Patch
Comment 3 chris fleizach 2023-05-13 13:12:17 PDT
Do we need those methods that set the focused tree when we add or update? Seems like maybe this assumption won't always be true
Comment 4 chris fleizach 2023-05-13 13:12:49 PDT
Can we assert that callers to focusedTree() will not be on the main thread?
Comment 5 Tyler Wilcock 2023-05-13 17:13:24 PDT
Comment on attachment 466345 [details]
Patch

View in context: https://bugs.webkit.org/attachment.cgi?id=466345&action=review

NSApplicationAccessibilityFocusedUIElement currently loops through the web process' web pages to get the focused one, and then calls AXObjectCache::focusedObjectForPage, which does a few transformations that I think your patch doesn't cover now (e.g. active descendant handling, image map focus handling).

Also, I don't think we have layout tests covering NSApplicationAccessibilityFocusedUIElement, so there's no way to know our behavior is the same before and after this patch. It would be nice to have tests simulating the tricky bits here -- e.g. removing a focused tree and expecting some result, focusing different webpages within the process, etc.

> Source/WebCore/accessibility/AXTreeStore.h:111
> +    // Assume that if a new tree is being added to the store, this must be the focused tree.
> +    setFocusedTreeID(axID);

What if we're adding a tree that has no focused element? It probably shouldn't overwrite the previously focused tree in that case.

> Source/WebCore/accessibility/AXTreeStore.h:143
> +    // If the tree being removed is the focused tree, make s_focusedTreeID invalid.
> +    {
> +        Locker locker { s_storeLock };
> +        if (axID == s_focusedTreeID)
> +            s_focusedTreeID = { };
> +    }

In this case, shouldn't the focused tree become the second most recently focused tree and not empty? E.g., given this sequence:

1. Tree A is added
2. Element in Tree A is focused, so A is now focused tree
3. Tree B is added with a focused element, tree B is now focused tree
4. Tree B is removed, but A still has a focused element, so it should be the focused tree. But in this patch, we will have no focused tree, which is not accurate, right?
Comment 6 Andres Gonzalez 2023-05-16 13:37:18 PDT
Created attachment 466370 [details]
Patch
Comment 7 Andres Gonzalez 2023-05-16 13:40:45 PDT
(In reply to chris fleizach from comment #3)
> Do we need those methods that set the focused tree when we add or update?
> Seems like maybe this assumption won't always be true

Reworked the mechanism to keep track of the focused Page based on the ActivityState which is what it is used behind the scene by the Page and WebPage.
Comment 8 Andres Gonzalez 2023-05-16 13:50:57 PDT
(In reply to Tyler Wilcock from comment #5)
> Comment on attachment 466345 [details]
> Patch
> 
> View in context:
> https://bugs.webkit.org/attachment.cgi?id=466345&action=review
> 
> NSApplicationAccessibilityFocusedUIElement currently loops through the web
> process' web pages to get the focused one, and then calls
> AXObjectCache::focusedObjectForPage, which does a few transformations that I
> think your patch doesn't cover now (e.g. active descendant handling, image
> map focus handling).

We need to handle those special cases as a result of a notification, not here. That way the AXIsolatedTree would know the focused object at any time. That should be a separate fix if we are not doing that already.
> 
> Also, I don't think we have layout tests covering
> NSApplicationAccessibilityFocusedUIElement, so there's no way to know our
> behavior is the same before and after this patch. It would be nice to have
> tests simulating the tricky bits here -- e.g. removing a focused tree and
> expecting some result, focusing different webpages within the process, etc.
> 
> > Source/WebCore/accessibility/AXTreeStore.h:111
> > +    // Assume that if a new tree is being added to the store, this must be the focused tree.
> > +    setFocusedTreeID(axID);
> 
> What if we're adding a tree that has no focused element? It probably
> shouldn't overwrite the previously focused tree in that case.
> 
> > Source/WebCore/accessibility/AXTreeStore.h:143
> > +    // If the tree being removed is the focused tree, make s_focusedTreeID invalid.
> > +    {
> > +        Locker locker { s_storeLock };
> > +        if (axID == s_focusedTreeID)
> > +            s_focusedTreeID = { };
> > +    }
> 
> In this case, shouldn't the focused tree become the second most recently
> focused tree and not empty? E.g., given this sequence:
> 
> 1. Tree A is added
> 2. Element in Tree A is focused, so A is now focused tree
> 3. Tree B is added with a focused element, tree B is now focused tree
> 4. Tree B is removed, but A still has a focused element, so it should be the
> focused tree. But in this patch, we will have no focused tree, which is not
> accurate, right?

Reworked all this to track the focused tree using the Page ActivityState.
Comment 9 Tyler Wilcock 2023-05-16 15:05:33 PDT
Comment on attachment 466370 [details]
Patch

View in context: https://bugs.webkit.org/attachment.cgi?id=466370&action=review

Nice, this approach matches the existing one nicely! Is it possible to add a test for this?

> Source/WebCore/accessibility/AXObjectCache.cpp:254
> +        m_pageActivityState = m_document.page()->activityState();

Is it safe to unconditionally dereference page() here? Some code above null-checks page().

> Source/WebCore/accessibility/isolatedtree/AXIsolatedTree.cpp:683
> +void AXIsolatedTree::setPageActivityState(OptionSet<ActivityState> state)
> +{
> +    ASSERT(isMainThread());
> +
> +    Locker locker { s_storeLock };
> +    m_pageActivityState = state;
> +}

Nit, feel free to not address, but seems a little strange to use the s_storeLock for this purpose? Modifying this data doesn't feel related to the AXTreeStore. Maybe it makes sense to have a dedicated lock -- perhaps there's some reason that wouldn't work, though, since I think we rely on holding the lock when querying this state in the new findTree() method.

> Source/WebCore/accessibility/isolatedtree/AXIsolatedTree.h:320
> +    // Use only if the s_storeLock is already held.
> +    OptionSet<ActivityState> lockedPageActivityState() const { return m_pageActivityState; }

Would it be useful to ASSERT(s_storeLock.isLocked())?
Comment 10 Andres Gonzalez 2023-06-01 18:19:04 PDT
Created attachment 466566 [details]
Patch
Comment 11 Andres Gonzalez 2023-06-01 18:44:49 PDT
Created attachment 466568 [details]
Patch
Comment 12 Andres Gonzalez 2023-06-01 18:53:49 PDT
(In reply to Tyler Wilcock from comment #9)
> Comment on attachment 466370 [details]
> Patch
> 
> View in context:
> https://bugs.webkit.org/attachment.cgi?id=466370&action=review
> 
> Nice, this approach matches the existing one nicely! Is it possible to add a
> test for this?

Now all tests that call accessibilityController::focusedElement are using this, more than 500 calls in LayoutTests/accessibility.
> 
> > Source/WebCore/accessibility/AXObjectCache.cpp:254
> > +        m_pageActivityState = m_document.page()->activityState();
> 
> Is it safe to unconditionally dereference page() here? Some code above
> null-checks page().

The only way there is an m_pageID is if document.page() is not null
> 
> > Source/WebCore/accessibility/isolatedtree/AXIsolatedTree.cpp:683
> > +void AXIsolatedTree::setPageActivityState(OptionSet<ActivityState> state)
> > +{
> > +    ASSERT(isMainThread());
> > +
> > +    Locker locker { s_storeLock };
> > +    m_pageActivityState = state;
> > +}
> 
> Nit, feel free to not address, but seems a little strange to use the
> s_storeLock for this purpose? Modifying this data doesn't feel related to
> the AXTreeStore. Maybe it makes sense to have a dedicated lock -- perhaps
> there's some reason that wouldn't work, though, since I think we rely on
> holding the lock when querying this state in the new findTree() method.

Yes, we need to hold the store Lock because of finding the focused tree with findAXTree.
> 
> > Source/WebCore/accessibility/isolatedtree/AXIsolatedTree.h:320
> > +    // Use only if the s_storeLock is already held.
> > +    OptionSet<ActivityState> lockedPageActivityState() const { return m_pageActivityState; }
> 
> Would it be useful to ASSERT(s_storeLock.isLocked())?

Done. Thanks.
Comment 13 Andres Gonzalez 2023-06-02 10:22:58 PDT
Created attachment 466571 [details]
Patch
Comment 14 Tyler Wilcock 2023-06-02 19:57:31 PDT
Comment on attachment 466571 [details]
Patch

View in context: https://bugs.webkit.org/attachment.cgi?id=466571&action=review

> Source/WebCore/accessibility/AXTreeStore.h:87
> -    static HashMap<AXID, ThreadSafeWeakPtr<AXIsolatedTree>>& isolatedTreeMap() WTF_REQUIRES_LOCK(s_storeLock);
> +    static HashMap<AXID, ThreadSafeWeakPtr<AXIsolatedTree>>& isolatedTreeMap();

Why remove WTF_REQUIRES_LOCK(s_storeLock)? Seems like we still acquire the lock before calling this, right?

> Source/WebCore/page/Page.cpp:2683
> +    auto* localMainFrame = dynamicDowncast<LocalFrame>(m_mainFrame.get());

Should this be a WeakPtr / RefPtr? Seems like non-trivial work happens after we create this pointer, potentially destroying it.

> Source/WebKit/WebProcess/cocoa/WebProcessCocoa.mm:242
> +#if ENABLE(ACCESSIBILITY_ISOLATED_TREE)
> +    if (!isMainRunLoop()) {
> +        // Avoid hitting the main thread by getting the focused object from the focused isolated tree.
> +        auto tree = findAXTree([] (AXTreePtr tree) -> bool {
> +            OptionSet<ActivityState> state;
> +            switchOn(tree,
> +                [&state] (RefPtr<AXIsolatedTree>& typedTree) {
> +                    if (typedTree)
> +                        state = typedTree->lockedPageActivityState();
> +                }
> +                , [] (auto&) { }
> +            );
> +            return state.containsAll({ ActivityState::IsVisible, ActivityState::IsFocused, ActivityState::WindowIsActive });
> +        });
> +
> +        RefPtr object = switchOn(tree,
> +            [] (RefPtr<AXIsolatedTree>& typedTree) -> RefPtr<AXIsolatedObject> {
> +                return typedTree ? typedTree->focusedNode() : nullptr;
> +            }
> +            , [] (auto&) -> RefPtr<AXIsolatedObject> {
> +                return nullptr;
> +            }
> +        );
> +        return object ? object->wrapper() : nil;
> +    }
> +#endif
> +
> +    WebPage* page = WebProcess::singleton().focusedWebPage();
> +    if (!page || !page->accessibilityRemoteObject())
> +        return nil;
> +    return [page->accessibilityRemoteObject() accessibilityFocusedUIElement];

This new off main-thread codepath will result in different behavior because AXObjectCache::focusedObjectForPage handled two special cases:

    Element* focusedElement = document->focusedElement();
    if (is<HTMLAreaElement>(focusedElement))
        return focusedImageMapUIElement(downcast<HTMLAreaElement>(focusedElement));

and 

    if (focus->shouldFocusActiveDescendant()) {
        if (auto* descendant = focus->activeDescendant())
            focus = descendant;
    }

The former special case has a layout test (fast/events/tab-imagemap.html), but unfortunately said layout test doesn't test any accessibility state. It was added in https://github.com/WebKit/WebKit/commit/a5d019bcb572b5b60605910ee6ed3238f88848c5.

The latter special case (aria-activedescendant) does not have a layout test at all (added in 2008 with https://github.com/WebKit/WebKit/commit/eb24533a042e5e82dfad152998ac6dced1f8be39).

There's also this case at the bottom:

    // the HTML element, for example, is focusable but has an AX object that is ignored
    if (focus->accessibilityIsIgnored())
        focus = focus->parentObjectUnignored();

We'll probably need to do something here too when we compute the focused ITM node (if we don't already).

I understand these probably make sense as separate changes. But it makes me nervous to land this patch and have things be broken for an unknown amount of time until we land those follow-up fixes. What do you think is the best way forward?
Comment 15 Andres Gonzalez 2023-06-05 08:11:23 PDT
Created attachment 466593 [details]
Patch
Comment 16 Andres Gonzalez 2023-06-06 14:09:11 PDT
Created attachment 466606 [details]
Patch
Comment 17 Andres Gonzalez 2023-06-06 14:15:00 PDT
(In reply to Tyler Wilcock from comment #14)
> Comment on attachment 466571 [details]
> Patch
> 
> View in context:
> https://bugs.webkit.org/attachment.cgi?id=466571&action=review
> 
> > Source/WebCore/accessibility/AXTreeStore.h:87
> > -    static HashMap<AXID, ThreadSafeWeakPtr<AXIsolatedTree>>& isolatedTreeMap() WTF_REQUIRES_LOCK(s_storeLock);
> > +    static HashMap<AXID, ThreadSafeWeakPtr<AXIsolatedTree>>& isolatedTreeMap();
> 
> Why remove WTF_REQUIRES_LOCK(s_storeLock)? Seems like we still acquire the
> lock before calling this, right?

Put it back, at some point it was giving me an error because it was used inside a lambda, but seems ok now.
> 
> > Source/WebCore/page/Page.cpp:2683
> > +    auto* localMainFrame = dynamicDowncast<LocalFrame>(m_mainFrame.get());
> 
> Should this be a WeakPtr / RefPtr? Seems like non-trivial work happens after
> we create this pointer, potentially destroying it.

Fixed.
> 
> > Source/WebKit/WebProcess/cocoa/WebProcessCocoa.mm:242
> > +#if ENABLE(ACCESSIBILITY_ISOLATED_TREE)
> > +    if (!isMainRunLoop()) {
> > +        // Avoid hitting the main thread by getting the focused object from the focused isolated tree.
> > +        auto tree = findAXTree([] (AXTreePtr tree) -> bool {
> > +            OptionSet<ActivityState> state;
> > +            switchOn(tree,
> > +                [&state] (RefPtr<AXIsolatedTree>& typedTree) {
> > +                    if (typedTree)
> > +                        state = typedTree->lockedPageActivityState();
> > +                }
> > +                , [] (auto&) { }
> > +            );
> > +            return state.containsAll({ ActivityState::IsVisible, ActivityState::IsFocused, ActivityState::WindowIsActive });
> > +        });
> > +
> > +        RefPtr object = switchOn(tree,
> > +            [] (RefPtr<AXIsolatedTree>& typedTree) -> RefPtr<AXIsolatedObject> {
> > +                return typedTree ? typedTree->focusedNode() : nullptr;
> > +            }
> > +            , [] (auto&) -> RefPtr<AXIsolatedObject> {
> > +                return nullptr;
> > +            }
> > +        );
> > +        return object ? object->wrapper() : nil;
> > +    }
> > +#endif
> > +
> > +    WebPage* page = WebProcess::singleton().focusedWebPage();
> > +    if (!page || !page->accessibilityRemoteObject())
> > +        return nil;
> > +    return [page->accessibilityRemoteObject() accessibilityFocusedUIElement];
> 
> This new off main-thread codepath will result in different behavior because
> AXObjectCache::focusedObjectForPage handled two special cases:
> 
>     Element* focusedElement = document->focusedElement();
>     if (is<HTMLAreaElement>(focusedElement))
>         return
> focusedImageMapUIElement(downcast<HTMLAreaElement>(focusedElement));
> 
> and 
> 
>     if (focus->shouldFocusActiveDescendant()) {
>         if (auto* descendant = focus->activeDescendant())
>             focus = descendant;
>     }
> 
> The former special case has a layout test (fast/events/tab-imagemap.html),
> but unfortunately said layout test doesn't test any accessibility state. It
> was added in
> https://github.com/WebKit/WebKit/commit/
> a5d019bcb572b5b60605910ee6ed3238f88848c5.
> 
> The latter special case (aria-activedescendant) does not have a layout test
> at all (added in 2008 with
> https://github.com/WebKit/WebKit/commit/
> eb24533a042e5e82dfad152998ac6dced1f8be39).
> 
> There's also this case at the bottom:
> 
>     // the HTML element, for example, is focusable but has an AX object that
> is ignored
>     if (focus->accessibilityIsIgnored())
>         focus = focus->parentObjectUnignored();
> 
> We'll probably need to do something here too when we compute the focused ITM
> node (if we don't already).
> 
> I understand these probably make sense as separate changes. But it makes me
> nervous to land this patch and have things be broken for an unknown amount
> of time until we land those follow-up fixes. What do you think is the best
> way forward?

See https://bugs.webkit.org/show_bug.cgi?id=257739. Thanks.
Comment 18 Andres Gonzalez 2023-06-06 15:02:49 PDT
Created attachment 466607 [details]
Patch
Comment 19 Andres Gonzalez 2023-06-07 11:38:54 PDT
Created attachment 466627 [details]
Patch
Comment 20 Andres Gonzalez 2023-06-07 17:27:31 PDT
Created attachment 466631 [details]
Patch
Comment 21 Andres Gonzalez 2023-06-08 06:07:43 PDT
Created attachment 466635 [details]
Patch
Comment 22 EWS 2023-06-08 07:33:43 PDT
Committed 264985@main (4ca184ab5339): <https://commits.webkit.org/264985@main>

All reviewed patches have been landed. Closing bug and clearing flags on attachment 466635 [details].