Skip to content

[macOS] Accessibility bridge keeps freed AXNodes after a failed AXTree update and crashes on the next AX query #193588

Closed

Description

@amantinband

Steps to reproduce

Release-mode macOS app (engine revision 0cd610717b, Flutter 3.44.8 stable, framework 058e0af2c2), accessibility in use (VoiceOver, an AX client such as a screen recorder or a password manager, or just AppKit's own tracking-area collection on a mouse move). The framework commits a semantics update in which a parent claims a child that has MOVED without sending that child in the same update, the OverlayPortal traversal graft shape described in #182444. Two commits later the app crashes on the main thread in the next accessibility query.

A deterministic unit reproduction on the stock engine at this pin, release config, in accessibility_bridge_unittests.cc: build the tree 0 > [1 > 3 > 4], 2, 5 > 6, then send ONE commit with 1 without child 3, 2 claiming 3, and 5 without child 6 (nodes 3 and 4 not sent). Then walk every delegate's children.

Expected results

Either the update is rejected atomically (the tree and the bridge stay at the previous consistent state) or it is applied consistently (delegates and cached child counts match the tree). Later commits keep applying.

Actual results

  1. AccessibilityBridge::CommitUpdates first removes the child from its old parent, then applies a second update that passes AXTree::ComputePendingChanges but fails in ValidatePendingChangesComplete ("Nodes left pending by the update: 3"), AFTER UpdateNode has already run DeleteOldChildren (nodes freed) and created new nodes.
  2. AXTree::Unserialize returns false before the observer notifications and before the unignored-child cache refresh. The bridge therefore keeps delegates whose ax_node_ is freed, has no delegate for the new nodes, and leaves parents whose cached unignored child count exceeds their real children.
  3. CommitUpdates gates on the sticky AXTree::error() string (never cleared) instead of the return value, and its first-update error path is an assert(false) that release builds compile out and that returns without clearing the pending updates. Every later commit is refused, so the bridge never recovers.
  4. The next AppKit query dereferences the stale state. Two field signatures from a shipping app, symbolized against the release FlutterMacOS:
    • a mouse-moved tracking-area update: -[FlutterTextField frame] (FlutterTextInputSemanticsObject.mm:97) into FlutterTextPlatformNode::GetFrame (:186) into AXTree::RelativeToTreeBounds on a freed AXNode.
    • an out-of-process AX query (NSAccessibilityChildren): -[AXPlatformNodeCocoa AXChildrenInternal] (ax_platform_node_mac.mm:651) into FlutterPlatformNodeDelegate::ChildAtIndex (flutter_platform_node_delegate.cc:90), fault address 0x48 (AXNodeData::id read through a null child): GetChildCount() returned the stale cached count and GetUnignoredChildAtIndex(index) returned nullptr.

In the unit cell: the bridge logs Failed to update ui::AXTree, error: Nodes left pending by the update: 3, the delegate for 6 outlives its node, node 3 has no delegate, node 5 reports one unignored child while GetUnignoredChildAtIndex(0) is null, and the walk crashes with EXC_BAD_ACCESS (code=1, address=0x48), frame 0 ui::AXNode::id(this=0x0) (ax_node.h:110), frame 1 FlutterPlatformNodeDelegate::ChildAtIndex(index=0) (flutter_platform_node_delegate.cc:90). A second cell in FlutterTextInputSemanticsObjectTest.mm, same commit with 6 a text field: the dropped FlutterTextField stays attached to the view and its frame reads the freed node.

A plain move (a node moved AND sent) is safe at this pin: the bridge's two updates delete and recreate the node and replace its delegate.

What we changed locally (happy to send it as a PR)

  • accessibility_bridge.{h,cc}: CommitUpdates branches on the Unserialize return value, logs the error, and calls a new RecoverFromFailedUpdate that drops delegates whose node the tree no longer holds, creates delegates for tree nodes that have none, recomputes every node's unignored child cache, and clears the pending updates and queued events. Nothing gates on the sticky error string any more, so later commits apply and fire events normally.
  • flutter_platform_node_delegate.{h,cc}: a delegate whose node lives in its bridge's tree resolves the node by id (a new OwnerBridge::GetNodeFromTree) on every call, and every accessor answers the empty value when the node is gone (0 children, a null child, empty data, an empty rect, no parent, no ancestor, false from AccessibilityPerformAction). ChildAtIndex guards a null GetUnignoredChildAtIndex.
  • FlutterTextInputSemanticsObject.mm: FlutterTextPlatformNode::GetFrame answers NSZeroRect when the node is gone. FlutterPlatformNodeDelegateMac.mm and flutter_platform_node_delegate_windows.cc guard their one direct GetAXNode() use.

With the patch the two cells pass, common_cpp_unittests passes 234 of 234, and the macOS accessibility and text-field suites pass 12 of 12.

Related: #182444 (the graft shape that produces the update), #187198 (OverlayPortal semantics crashes on macOS).

Code sample

The two unit cells above are the smallest reproduction we have. The app-level trigger is any OverlayPortal graft under an AX client. We can attach the cells as a patch on request.

Logs

Failed to update ui::AXTree, error: Nodes left pending by the update: 3
EXC_BAD_ACCESS (code=1, address=0x48)
0  ui::AXNode::id(this=0x0)  ax_node.h:110
1  flutter::FlutterPlatformNodeDelegate::ChildAtIndex(index=0)  flutter_platform_node_delegate.cc:90
2  -[AXPlatformNodeCocoa AXChildrenInternal]  ax_platform_node_mac.mm:651

Flutter Doctor output

Flutter 3.44.8 • channel stable • https://github.com/flutter/flutter.git
Framework • revision 058e0af2c2 (2026-07-23)
Engine • hash 13ffd72b2f9a5ca4db2a74ea52d5353ec2e8f939 (revision 0cd610717b) (2026-07-23)
Tools • Dart 3.12.2 • DevTools 2.57.0

Activity

  1. added
    c: crashStack traces logged to the console
    engineflutter/engine related. See also e: labels.
    a: accessibilityAccessibility, e.g. VoiceOver or TalkBack. (aka a11y)
    platform-macosBuilding on or for macOS specifically
    team-accessibilityOwned by Framework Accessibility team (i.e. responsible for accessibility code in flutter/flutter)
    fyi-macosFor the attention of macOS platform team
    on Sep 30, 2026
  2. mbcorona commented on Sep 30, 2026

    @mbcorona
    Member

    This is distinct from the framework side fix in #187198 / #190431: those stop the corrupt semantics update from being produced, whereas this is the engine accessibility bridge failing to recover from such an update.

    Routing to team-accessibility.

  3. changed the title [-][macOS] Accessibility bridge keeps freed AXNodes after a failed AXTree update (a parent claims a moved child that is not sent), then crashes in FlutterPlatformNodeDelegate::ChildAtIndex or -[FlutterTextField frame][/-] [+][macOS] Accessibility bridge keeps freed AXNodes after a failed AXTree update and crashes on the next AX query[/+] on Sep 30, 2026
  4. cbracken commented on Oct 1, 2026

    @cbracken
    Member

    /cc @chunhtai who is probably most familiar with this code.

  5. CanArslanDev commented on Oct 2, 2026

    @CanArslanDev
    Contributor

    Small note on the framework side, since this got linked to #190431. That PR does not change the shape described here. I recorded the semantics updates sent to the engine for the #182444 repro and for the MenuAnchor + Tooltip one from that thread, checked them against the rules the bridge applies, and got the same orphan nodes with and without #190431. #190344 was the framework fix for that shape and it was closed without merging, so as far as I can tell nothing on the framework side stops this update from being produced right now.

  6. hannah-hyj commented on Oct 7, 2026

    @hannah-hyj
    Member

    From triage: I will test offline if this is related to #190357

  7. hannah-hyj commented on Oct 8, 2026

    @hannah-hyj
    Member

    I think this its related to #190357 but not excatly duplicate.

    The root cause is that when there's a framework bug that sends an invalid semantics update, the engine accessibility bridge also doesn't recover after an update fails. On macOS that leads to a crash.

    I will try to fix the framework side issue on #190357. but reassign this issue to team-macos for them to decide if an engine side fix is neccessary.

  8. added
    team-macosOwned by the macOS platform team
    and removed
    team-accessibilityOwned by Framework Accessibility team (i.e. responsible for accessibility code in flutter/flutter)
    on Oct 8, 2026
  9. flutter-triage-bot commented on Oct 13, 2026

    @flutter-triage-bot

    The fyi-macos label is redundant with the team-macos label.
    The triaged-accessibility label is irrelevant if there is no team-accessibility label or fyi-accessibility label.
    This issue is missing a priority label. Please set a priority label when adding the triaged-macos label.

  10. vashworth commented on Oct 14, 2026

    @vashworth
    Contributor

    @hannah-hyj Were you able to reproduce? Can you add the repo example?

  11. hannah-hyj commented on Oct 16, 2026

    @hannah-hyj
    Member

    @vashworth Here are the app-level (main.dart) and engine unit test reproductions:

    1. Flutter app reproduction (main.dart)

    (Note: #193372 will fix the framework side trigger)

    Run in release mode (flutter run -d macos --release — in debug mode, AccessibilityBridge::CreateRemoveReparentedNodesUpdate hits assert(pending_semantics_node_updates_.find(child_id) != ...) first) with VoiceOver enabled (or another macOS accessibility client active):

    1. Click the "Trigger reparent + remove" button.
    2. Move the mouse over the window or navigate with VoiceOver.
    3. The console logs:
      [ERROR:flutter/shell/platform/common/accessibility_bridge.cc(114)] Failed to update ui::AXTree, error: Nodes left pending by the update: ...
      
      followed by a main-thread EXC_BAD_ACCESS crash in FlutterTextPlatformNode::GetFrame / FlutterPlatformNodeDelegate::ChildAtIndex because AXTree::Unserialize freed the removed child node before aborting, leaving stale delegates and cached child counts in AccessibilityBridge.
    import 'package:flutter/material.dart';
    
    void main() => runApp(const MaterialApp(home: ReproPage()));
    
    class ReproPage extends StatefulWidget {
      const ReproPage({super.key});
    
      @override
      State<ReproPage> createState() => _ReproPageState();
    }
    
    class _ReproPageState extends State<ReproPage> {
      final OverlayPortalController _portalController = OverlayPortalController()..show();
      final GlobalKey _portalKey = GlobalKey();
      bool _moved = false;
    
      @override
      Widget build(BuildContext context) {
        final Widget portal = OverlayPortal(
          key: _portalKey,
          controller: _portalController,
          overlayChildBuilder: (BuildContext context) {
            return Positioned(
              left: 20,
              top: 20,
              child: Semantics(
                container: true,
                label: 'portal-3',
                child: Semantics(
                  container: true,
                  label: 'portal-4',
                  child: const SizedBox(width: 40, height: 40),
                ),
              ),
            );
          },
          child: const Text('anchor'),
        );
    
        return Scaffold(
          body: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              ElevatedButton(
                onPressed: () => setState(() => _moved = !_moved),
                child: const Text('Trigger reparent + remove'),
              ),
              // Node 1: initially owns the OverlayPortal traversal parent, then drops it.
              Semantics(
                container: true,
                label: 'parent-1',
                child: !_moved ? portal : const SizedBox(width: 40, height: 40),
              ),
              // Node 2: claims the moved OverlayPortal traversal child without node 3 being dirty.
              Semantics(
                container: true,
                label: 'parent-2',
                child: _moved ? portal : const SizedBox(width: 40, height: 40),
              ),
              // Node 5 -> Node 6: drops its child in the same commit so AXTree::UpdateNode
              // frees Node 6 before ValidatePendingChangesComplete fails on Node 3.
              Semantics(
                container: true,
                label: 'parent-5',
                child: !_moved
                    ? const SizedBox(width: 200, child: TextField())
                    : const SizedBox(width: 40, height: 40),
              ),
            ],
          ),
        );
      }
    }

    2. Engine unit test reproduction (accessibility_bridge_unittests.cc)

    To reproduce deterministically in engine/src/flutter/shell/platform/common/accessibility_bridge_unittests.cc (in release mode where the assert in CreateRemoveReparentedNodesUpdate is compiled out):

    TEST(AccessibilityBridgeTest, FailedUpdateDoesNotLeaveDanglingNodes) {
      std::shared_ptr<TestAccessibilityBridge> bridge =
          std::make_shared<TestAccessibilityBridge>();
    
      // Initial tree: 0 -> [1 -> (3 -> 4), 2, 5 -> 6]
      std::vector<int32_t> root_children{1, 2, 5};
      std::vector<int32_t> node1_children{3};
      std::vector<int32_t> node3_children{4};
      std::vector<int32_t> node5_children{6};
    
      bridge->AddFlutterSemanticsNodeUpdate(
          CreateSemanticsNode(0, "root", &root_children));
      bridge->AddFlutterSemanticsNodeUpdate(
          CreateSemanticsNode(1, "node 1", &node1_children));
      bridge->AddFlutterSemanticsNodeUpdate(CreateSemanticsNode(2, "node 2"));
      bridge->AddFlutterSemanticsNodeUpdate(
          CreateSemanticsNode(3, "node 3", &node3_children));
      bridge->AddFlutterSemanticsNodeUpdate(CreateSemanticsNode(4, "node 4"));
      bridge->AddFlutterSemanticsNodeUpdate(
          CreateSemanticsNode(5, "node 5", &node5_children));
      bridge->AddFlutterSemanticsNodeUpdate(CreateSemanticsNode(6, "node 6"));
      bridge->CommitUpdates();
    
      // Second commit:
      // - 1 drops child 3
      // - 2 claims child 3 (without sending 3 or 4 in this update)
      // - 5 drops child 6
      std::vector<int32_t> new_node2_children{3};
      bridge->AddFlutterSemanticsNodeUpdate(CreateSemanticsNode(1, "node 1"));
      bridge->AddFlutterSemanticsNodeUpdate(
          CreateSemanticsNode(2, "node 2", &new_node2_children));
      bridge->AddFlutterSemanticsNodeUpdate(CreateSemanticsNode(5, "node 5"));
      bridge->CommitUpdates();
    
      // Walking node 5's children crashes with EXC_BAD_ACCESS (null AXNode deref)
      // because node 6 was freed during UpdateNode before Unserialize failed,
      // leaving node 5's cached unignored child count at 1.
      auto node5 = bridge->GetFlutterPlatformNodeDelegateFromID(5).lock();
      ASSERT_TRUE(node5);
      for (size_t i = 0; i < node5->GetChildCount(); ++i) {
        node5->ChildAtIndex(i);
      }
    }
  12. hannah-hyj commented on Oct 16, 2026

    @hannah-hyj
    Member

    since #193372 will fix the framework side trigger , I think we can wait for the PR to land, and then mark the engine side fix as a P2, I'm not even sure the enigne side fix is needed, maybe we should just assume the framework should always be responsible to send correct semantics updates.

  13. LongCatIsLooong commented on Oct 23, 2026

    @LongCatIsLooong
    Contributor

    @amantinband could you check out the main channel and see if the issue persists?

  14. added
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    on Oct 23, 2026
  15. github-actions commented on Nov 13, 2026

    @github-actions

    Without additional information, we are unfortunately not sure how to resolve this issue. We are therefore reluctantly going to close this bug for now.

    If you find this problem please file a new issue with the same description, what happens, logs and the output of 'flutter doctor -v'. All system setups can be slightly different so it's always better to open new issues and reference the related ones.

    Thanks for your contribution.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    a: accessibilityAccessibility, e.g. VoiceOver or TalkBack. (aka a11y)c: crashStack traces logged to the consoleengineflutter/engine related. See also e: labels.platform-macosBuilding on or for macOS specificallyteam-macosOwned by the macOS platform teamwaiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions