Linux maintainer tooling and workflows
 help / color / mirror / Atom feed
From: Jiri Slaby <jirislaby@kernel.org>
To: tools@kernel.org
Subject: b4's tests fail randomly
Date: Mon, 9 Nov 2026 10:47:40 +0100	[thread overview]
Message-ID: <aa3c547b-daa6-4daf-9b64-cb5f10995b32@kernel.org> (raw)

[-- Attachment #1: Type: text/plain, Size: 2415 bytes --]

Hi,

with b4 0.16.0, I've seen these tests failures (attachments). This was 
on i586 only -- but that's likely due to slower builds in a VM, so 
likely a special timing. They also fail randomly.

Claude came up with the two attached patches, which I am not sure are 
correct. But they indeed work around the issues.

Claude's output:

   1. Slow-startup timing race

   A test asserted on the tracking list contents right after a single
   pilot.pause(). pilot.pause()'s "idle" heuristic compares wall-clock
   time to this process's own CPU time — but on_mount() spawns real git
   subprocesses (branch-tip lookup + a background _startup_rescan
   worker thread) before _refresh_list() actually mounts
   #tracking-list. While blocked on a forked subprocess, the process
   isn't burning CPU, so the heuristic can call the app "idle" before
   mounting has actually finished — especially on slow/emulated
   builders where fork+exec overhead is much higher in wall-clock
   terms. Fixed by adding an explicit await pilot.pause(0.5).

   2. Footer-race crash

   TestTargetBranch::test_clear_target_branch crashed with NoMatches:
   No nodes match 'Footer' inside _refresh_list(). Root cause:
   _refresh_list() already guards one widget lookup (#title-left)
   against NoMatches for "modal active / app shutting down," but three
   later self.query_one(Footer) calls weren't guarded. On the slow
   builder, the 1-second _check_db_changed timer can schedule another
   refresh (triggered by the DB write from clearing the target branch);
   if that refresh is still waiting on a slow git subprocess when the
   test's run_test() block exits and the app starts tearing down, the
   Footer widget gets unmounted before the resumed refresh looks it up,
   raising an unguarded NoMatches. Fixed by applying the same
   defensive try/except NoMatches pattern used for #title-left, looking
   up Footer once and reusing it for all three mount(..., before=...)
   calls.

   Common thread: both are the same underlying class of problem — code
   that assumes synchronous/fast completion of async UI operations,
   which only breaks down when wall-clock time diverges sharply from
   CPU time (i.e., emulated/slow builders like i586 QEMU). One was a
   test-side assertion racing app startup; the other is a real (if
   narrow) app-code race between a background refresh and app teardown.


thanks,
-- 
js
suse labs

[-- Attachment #2: b4_fail1.txt --]
[-- Type: text/plain, Size: 14254 bytes --]

> =================================== FAILURES ===================================
> __________________ TestTargetBranch.test_clear_target_branch ___________________
> 
> self = <tests.test_tui_tracking.TestTargetBranch object at 0xf46a4710>
> _mock_branches = <MagicMock name='get_review_target_branches' id='4095035968'>
> gitdir = '/tmp/pytest-of-abuild/pytest-0/test_clear_target_branch0/repo'
> 
>     @pytest.mark.asyncio
>     async def test_clear_target_branch(self, _mock_branches: Any, gitdir: str) -> None:
>         """Ctrl+d in modal clears the target branch."""
>         identifier = 'test-target-clear'
>         change_id = 'target-clear-1'
>         _seed_db(
>             identifier,
>             [
>                 {
>                     'change_id': change_id,
>                     'subject': '[PATCH] clear target test',
>                     'message_id': 'target-clear@ex.com',
>                 }
>             ],
>         )
>         # Set target first
>         conn = tracking.get_db(identifier)
>         tracking.update_target_branch(conn, change_id, 'old-branch')
>         conn.close()
>     
>         app = TrackingApp(identifier)
> >       async with app.run_test(size=(120, 30)) as pilot:
>                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
> 
> /home/abuild/rpmbuild/BUILD/b4-0.16.0-build/b4-0.16.0/src/tests/test_tui_tracking.py:3602: 
> _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
> /usr/lib/python3.13/contextlib.py:221: in __aexit__
>     await anext(self.gen)
> /usr/lib/python3.13/site-packages/textual/app.py:2218: in run_test
>     raise self._exception
> /usr/lib/python3.13/site-packages/textual/message_pump.py:663: in _process_messages_loop
>     await self._dispatch_message(message)
> /usr/lib/python3.13/site-packages/textual/message_pump.py:727: in _dispatch_message
>     await self.on_event(message)
> /usr/lib/python3.13/site-packages/textual/app.py:4148: in on_event
>     await super().on_event(event)
> /usr/lib/python3.13/site-packages/textual/message_pump.py:808: in on_event
>     await self._on_message(event)
> /usr/lib/python3.13/site-packages/textual/message_pump.py:829: in _on_message
>     await invoke(method, message)
> /usr/lib/python3.13/site-packages/textual/_callback.py:96: in invoke
>     return await _invoke(callback, *params)
>            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
> /usr/lib/python3.13/site-packages/textual/_callback.py:58: in _invoke
>     result = await result
>              ^^^^^^^^^^^^
> /usr/lib/python3.13/site-packages/textual/message_pump.py:900: in on_callback
>     await invoke(event.callback)
> /usr/lib/python3.13/site-packages/textual/_callback.py:96: in invoke
>     return await _invoke(callback, *params)
>            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
> /usr/lib/python3.13/site-packages/textual/_callback.py:58: in _invoke
>     result = await result
>              ^^^^^^^^^^^^
> /home/abuild/rpmbuild/BUILD/b4-0.16.0-build/b4-0.16.0/src/b4/review_tui/_tracking_app.py:1318: in _refresh_list
>     await self.mount(header, before=self.query_one(Footer))
>                                     ^^^^^^^^^^^^^^^^^^^^^^
> _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
> 
> self = TrackingApp(title='b4 review', classes={'-dark-mode'}, pseudo_classes={'dark', 'focus'})
> selector = <class 'textual.widgets._footer.Footer'>, expect_type = None
> 
>     def query_one(
>         self,
>         selector: str | type[QueryType],
>         expect_type: type[QueryType] | None = None,
>     ) -> QueryType | Widget:
>         """Get a widget from this widget's children that matches a selector or widget type.
>     
>         Args:
>             selector: A selector or widget type.
>             expect_type: Require the object be of the supplied type, or None for any type.
>     
>         Raises:
>             WrongType: If the wrong type was found.
>             NoMatches: If no node matches the query.
>     
>         Returns:
>             A widget matching the selector.
>         """
>         _rich_traceback_omit = True
>     
>         base_node = self._get_dom_base()
>     
>         if isinstance(selector, str):
>             query_selector = selector
>         else:
>             query_selector = selector.__name__
>     
>         if is_id_selector(query_selector):
>             cache_key = (base_node._nodes._updates, query_selector, expect_type)
>             cached_result = base_node._query_one_cache.get(cache_key)
>             if cached_result is not None:
>                 return cached_result
>             if (
>                 node := walk_breadth_search_id(
>                     base_node, query_selector[1:], with_root=False
>                 )
>             ) is not None:
>                 if expect_type is not None and not isinstance(node, expect_type):
>                     raise WrongType(
>                         f"Node matching {query_selector!r} is the wrong type; expected type {expect_type.__name__!r}, found {node}"
>                     )
>                 base_node._query_one_cache[cache_key] = node
>                 return node
>             raise NoMatches(f"No nodes match {query_selector!r} on {base_node!r}")
>     
>         try:
>             selector_set = parse_selectors(query_selector)
>         except TokenError:
>             raise InvalidQueryFormat(
>                 f"Unable to parse {query_selector!r} as a query; check for syntax errors"
>             ) from None
>     
>         if all(selectors.is_simple for selectors in selector_set):
>             cache_key = (base_node._nodes._updates, query_selector, expect_type)
>             cached_result = base_node._query_one_cache.get(cache_key)
>             if cached_result is not None:
>                 return cached_result
>         else:
>             cache_key = None
>     
>         for node in walk_breadth_first(base_node, with_root=False):
>             if not match(selector_set, node):
>                 continue
>             if expect_type is not None and not isinstance(node, expect_type):
>                 raise WrongType(
>                     f"Node matching {query_selector!r} is the wrong type; expected type {expect_type.__name__!r}, found {node}"
>                 )
>             if cache_key is not None:
>                 base_node._query_one_cache[cache_key] = node
>             return node
>     
> >       raise NoMatches(f"No nodes match {query_selector!r} on {base_node!r}")
> E       textual.css.query.NoMatches: No nodes match 'Footer' on Screen(id='_default')
> 
> /usr/lib/python3.13/site-packages/textual/dom.py:1533: NoMatches
> ------------------------------ Captured log setup ------------------------------
> DEBUG    b4:__init__.py:3639 Running git --no-pager rev-parse --show-toplevel
> DEBUG    b4:__init__.py:3639 Running git --no-pager clone --branch master /home/abuild/rpmbuild/BUILD/b4-0.16.0-build/b4-0.16.0/src/tests/samples/gitdir.bundle /tmp/pytest-of-abuild/pytest-0/test_clear_target_branch0/repo
> DEBUG    b4:__init__.py:3639 Running git --no-pager --git-dir /tmp/pytest-of-abuild/pytest-0/test_clear_target_branch0/repo/.git config --replace-all user.name Test Override
> DEBUG    b4:__init__.py:3639 Running git --no-pager --git-dir /tmp/pytest-of-abuild/pytest-0/test_clear_target_branch0/repo/.git config --replace-all user.email test-override@example.com
> DEBUG    asyncio:selector_events.py:64 Using selector: EpollSelector
> ----------------------------- Captured stderr call -----------------------------
> ╭───────────────────── Traceback (most recent call last) ──────────────────────╮
> │ /home/abuild/rpmbuild/BUILD/b4-0.16.0-build/b4-0.16.0/src/b4/review_tui/_tra │
> │ cking_app.py:1318 in _refresh_list                                           │
> │                                                                              │
> │   1315 │   │   │                                                             │
> │   1316 │   │   │   list_items: List[ListItem] = [TrackedSeriesItem(s) for s  │
> │   1317 │   │   │   lv = ReplacementListView(*list_items, id='tracking-list', │
> │ ❱ 1318 │   │   │   await self.mount(header, before=self.query_one(Footer))   │
> │   1319 │   │   │   await self.mount(lv, before=self.query_one(Footer))       │
> │   1320 │   │                                                                 │
> │   1321 │   │   new_index = 0                                                 │
> │                                                                              │
> │ ╭───────────────────────────────── locals ─────────────────────────────────╮ │
> │ │ display_series = [                                                       │ │
> │ │                  │   {                                                   │ │
> │ │                  │   │   'track_id': 1,                                  │ │
> │ │                  │   │   'change_id': 'target-clear-1',                  │ │
> │ │                  │   │   'revision': 1,                                  │ │
> │ │                  │   │   'subject': '[PATCH] clear target test',         │ │
> │ │                  │   │   'sender_name': 'Test Author',                   │ │
> │ │                  │   │   'sender_email': 'author@example.com',           │ │
> │ │                  │   │   'sent_at': '2026-01-15T10:00:00+00:00',         │ │
> │ │                  │   │   'added_at': '2026-11-08T05:57:49.091031+00:00', │ │
> │ │                  │   │   'status': 'new',                                │ │
> │ │                  │   │   'num_patches': 1,                               │ │
> │ │                  │   │   ... +10                                         │ │
> │ │                  │   }                                                   │ │
> │ │                  ]                                                       │ │
> │ │         header = Static(id='tracking-header')                            │ │
> │ │    header_text = 'Submitter           A   A·R·T    Msgs  S   Subject'    │ │
> │ │           left = Static(id='title-left')                                 │ │
> │ │     list_items = [TrackedSeriesItem()]                                   │ │
> │ │             lv = ReplacementListView(id='tracking-list')                 │ │
> │ │       scroll_y = 0.0                                                     │ │
> │ │           self = TrackingApp(                                            │ │
> │ │                  │   title='b4 review',                                  │ │
> │ │                  │   classes={'-dark-mode'},                             │ │
> │ │                  │   pseudo_classes={'dark', 'focus'}                    │ │
> │ │                  )                                                       │ │
> │ │     title_text = ' Tracked Series — test-target-clear'                   │ │
> │ │         widget = ReplacementListView(id='tracking-list')                 │ │
> │ ╰──────────────────────────────────────────────────────────────────────────╯ │
> ╰──────────────────────────────────────────────────────────────────────────────╯
> NoMatches: No nodes match 'Footer' on Screen(id='_default')
> ------------------------------ Captured log call -------------------------------
> DEBUG    b4:__init__.py:3639 Running git --no-pager rev-parse --git-common-dir
> DEBUG    b4:__init__.py:3639 Running git --no-pager rev-parse --show-toplevel
> DEBUG    b4:__init__.py:3639 Running git --no-pager rev-parse --show-toplevel
> DEBUG    b4:__init__.py:3639 Running git --no-pager rev-parse --show-toplevel
> DEBUG    b4:__init__.py:3639 Running git --no-pager --git-dir /tmp/pytest-of-abuild/pytest-0/test_clear_target_branch0/repo/.git for-each-ref --format=%(refname:short) %(objectname) refs/heads/b4/review/
> DEBUG    b4:__init__.py:3639 Running git --no-pager rev-parse --git-common-dir
> DEBUG    b4:__init__.py:3639 Running git --no-pager rev-parse --show-toplevel
> DEBUG    b4:__init__.py:3639 Running git --no-pager rev-parse --show-toplevel
> DEBUG    b4:__init__.py:3639 Running git --no-pager --git-dir /tmp/pytest-of-abuild/pytest-0/test_clear_target_branch0/repo/.git for-each-ref --format=%(refname:short) refs/heads/b4/review/
> DEBUG    b4:__init__.py:3639 Running git --no-pager rev-parse --show-toplevel
> DEBUG    b4:__init__.py:3639 Running git --no-pager rev-parse --show-toplevel
> DEBUG    b4:__init__.py:3639 Running git --no-pager --git-dir /tmp/pytest-of-abuild/pytest-0/test_clear_target_branch0/repo/.git rev-parse --git-common-dir
> DEBUG    b4:__init__.py:3639 Running git --no-pager rev-parse --show-toplevel
> DEBUG    b4:__init__.py:3639 Running git --no-pager rev-parse --show-toplevel
> DEBUG    b4:__init__.py:3639 Running git --no-pager rev-parse --show-toplevel
> DEBUG    b4:__init__.py:3639 Running git --no-pager --git-dir /tmp/pytest-of-abuild/pytest-0/test_clear_target_branch0/repo/.git for-each-ref --format=%(refname:short) %(objectname) refs/heads/b4/review/
> DEBUG    b4:__init__.py:3639 Running git --no-pager rev-parse --git-common-dir
> DEBUG    b4:__init__.py:3639 Running git --no-pager rev-parse --show-toplevel
> =========================== short test summary info ============================
> FAILED src/tests/test_tui_tracking.py::TestTargetBranch::test_clear_target_branch

[-- Attachment #3: b4_fail2.txt --]
[-- Type: text/plain, Size: 8538 bytes --]

=================================== FAILURES ===================================
_________ TestTrackingMultipleSeries.test_mixed_statuses_with_branches _________

self = <tests.test_tui_tracking.TestTrackingMultipleSeries object at 0xf473bf50>
gitdir = '/tmp/pytest-of-abuild/pytest-0/test_mixed_statuses_with_branc0/repo'

    @pytest.mark.asyncio
    async def test_mixed_statuses_with_branches(self, gitdir: str) -> None:
        """App should correctly display a mix of new and reviewing series."""
        identifier = 'test-mixed'
        change_id_rev = 'mixed-reviewing-1'
        _create_review_branch(
            gitdir, change_id_rev, identifier=identifier, subject='Reviewing series'
        )
        _seed_db(
            identifier,
            [
                {
                    'change_id': change_id_rev,
                    'subject': '[PATCH] reviewing series',
                    'status': 'reviewing',
                    'sent_at': '2026-03-10T12:00:00+00:00',
                    'message_id': 'rev@ex.com',
                },
                {
                    'change_id': 'mixed-new-1',
                    'subject': '[PATCH] new series',
                    'sent_at': '2026-03-10T11:00:00+00:00',
                    'message_id': 'new@ex.com',
                },
                {
                    'change_id': 'mixed-snoozed-1',
                    'subject': '[PATCH] snoozed series',
                    'status': 'snoozed',
                    'sent_at': '2026-03-10T10:00:00+00:00',
                    'message_id': 'snz@ex.com',
                },
            ],
        )
    
        app = TrackingApp(identifier)
        async with app.run_test(size=(120, 30)) as pilot:
            await pilot.pause()
>           lv = app.query_one('#tracking-list', ListView)
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

/home/abuild/rpmbuild/BUILD/b4-0.16.0-build/b4-0.16.0/src/tests/test_tui_tracking.py:2079: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = TrackingApp(title='b4 review', classes={'-dark-mode'}, pseudo_classes={'dark', 'focus'})
selector = '#tracking-list'
expect_type = <class 'textual.widgets._list_view.ListView'>

    def query_one(
        self,
        selector: str | type[QueryType],
        expect_type: type[QueryType] | None = None,
    ) -> QueryType | Widget:
        """Get a widget from this widget's children that matches a selector or widget type.
    
        Args:
            selector: A selector or widget type.
            expect_type: Require the object be of the supplied type, or None for any type.
    
        Raises:
            WrongType: If the wrong type was found.
            NoMatches: If no node matches the query.
    
        Returns:
            A widget matching the selector.
        """
        _rich_traceback_omit = True
    
        base_node = self._get_dom_base()
    
        if isinstance(selector, str):
            query_selector = selector
        else:
            query_selector = selector.__name__
    
        if is_id_selector(query_selector):
            cache_key = (base_node._nodes._updates, query_selector, expect_type)
            cached_result = base_node._query_one_cache.get(cache_key)
            if cached_result is not None:
                return cached_result
            if (
                node := walk_breadth_search_id(
                    base_node, query_selector[1:], with_root=False
                )
            ) is not None:
                if expect_type is not None and not isinstance(node, expect_type):
                    raise WrongType(
                        f"Node matching {query_selector!r} is the wrong type; expected type {expect_type.__name__!r}, found {node}"
                    )
                base_node._query_one_cache[cache_key] = node
                return node
>           raise NoMatches(f"No nodes match {query_selector!r} on {base_node!r}")
E           textual.css.query.NoMatches: No nodes match '#tracking-list' on Screen(id='_default')

/usr/lib/python3.13/site-packages/textual/dom.py:1505: NoMatches
------------------------------ Captured log setup ------------------------------
DEBUG    b4:__init__.py:3639 Running git --no-pager rev-parse --show-toplevel
DEBUG    b4:__init__.py:3639 Running git --no-pager clone --branch master /home/abuild/rpmbuild/BUILD/b4-0.16.0-build/b4-0.16.0/src/tests/samples/gitdir.bundle /tmp/pytest-of-abuild/pytest-0/test_mixed_statuses_with_branc0/repo
DEBUG    b4:__init__.py:3639 Running git --no-pager --git-dir /tmp/pytest-of-abuild/pytest-0/test_mixed_statuses_with_branc0/repo/.git config --replace-all user.name Test Override
DEBUG    b4:__init__.py:3639 Running git --no-pager --git-dir /tmp/pytest-of-abuild/pytest-0/test_mixed_statuses_with_branc0/repo/.git config --replace-all user.email test-override@example.com
DEBUG    asyncio:selector_events.py:64 Using selector: EpollSelector
------------------------------ Captured log call -------------------------------
DEBUG    b4:__init__.py:3639 Running git --no-pager --git-dir /tmp/pytest-of-abuild/pytest-0/test_mixed_statuses_with_branc0/repo/.git rev-parse HEAD
DEBUG    b4:__init__.py:3639 Running git --no-pager --git-dir /tmp/pytest-of-abuild/pytest-0/test_mixed_statuses_with_branc0/repo/.git branch b4/review/mixed-reviewing-1 f435c12df7c0ecf20ab8937859e63cddffacabb4
DEBUG    b4:__init__.py:3639 Running git --no-pager --git-dir /tmp/pytest-of-abuild/pytest-0/test_mixed_statuses_with_branc0/repo/.git rev-parse b4/review/mixed-reviewing-1^{tree}
DEBUG    b4:__init__.py:3639 Running git --no-pager --git-dir /tmp/pytest-of-abuild/pytest-0/test_mixed_statuses_with_branc0/repo/.git commit-tree 61998ae25be6e19ab86a44c461cf6b26786fbf0e -p f435c12df7c0ecf20ab8937859e63cddffacabb4
DEBUG    b4:__init__.py:3639 Running git --no-pager --git-dir /tmp/pytest-of-abuild/pytest-0/test_mixed_statuses_with_branc0/repo/.git update-ref refs/heads/b4/review/mixed-reviewing-1 da988b536839f1e3cd83dbca9cfeb8ae60dd433a
DEBUG    b4:__init__.py:3639 Running git --no-pager rev-parse --git-common-dir
DEBUG    b4:__init__.py:3639 Running git --no-pager rev-parse --show-toplevel
DEBUG    b4:__init__.py:3639 Running git --no-pager rev-parse --show-toplevel
DEBUG    b4:__init__.py:3639 Running git --no-pager rev-parse --show-toplevel
DEBUG    b4:__init__.py:3639 Running git --no-pager --git-dir /tmp/pytest-of-abuild/pytest-0/test_mixed_statuses_with_branc0/repo/.git for-each-ref --format=%(refname:short) %(objectname) refs/heads/b4/review/
DEBUG    b4:__init__.py:3639 Running git --no-pager --git-dir /tmp/pytest-of-abuild/pytest-0/test_mixed_statuses_with_branc0/repo/.git cat-file --batch
DEBUG    b4:__init__.py:3639 Running git --no-pager rev-parse --git-common-dir
DEBUG    b4:__init__.py:3639 Running git --no-pager rev-parse --show-toplevel
DEBUG    b4:__init__.py:3639 Running git --no-pager rev-parse --show-toplevel
DEBUG    b4:__init__.py:3639 Running git --no-pager --git-dir /tmp/pytest-of-abuild/pytest-0/test_mixed_statuses_with_branc0/repo/.git for-each-ref --format=%(refname:short) refs/heads/b4/review/
DEBUG    b4:__init__.py:3639 Running git --no-pager --git-dir /tmp/pytest-of-abuild/pytest-0/test_mixed_statuses_with_branc0/repo/.git rev-parse b4/review/mixed-reviewing-1
DEBUG    b4:__init__.py:3639 Running git --no-pager --git-dir /tmp/pytest-of-abuild/pytest-0/test_mixed_statuses_with_branc0/repo/.git log --no-abbrev-commit -1 --format=%B b4/review/mixed-reviewing-1
INFO     b4:tracking.py:2913 Rescanned: mixed-reviewing-1 (status: reviewing)
DEBUG    b4:__init__.py:3639 Running git --no-pager rev-parse --show-toplevel
DEBUG    b4:__init__.py:3639 Running git --no-pager rev-parse --show-toplevel
DEBUG    b4:__init__.py:3639 Running git --no-pager --git-dir /tmp/pytest-of-abuild/pytest-0/test_mixed_statuses_with_branc0/repo/.git for-each-ref --format=%(refname:short) %(objectname) refs/heads/b4/review/
DEBUG    b4:__init__.py:3639 Running git --no-pager --git-dir /tmp/pytest-of-abuild/pytest-0/test_mixed_statuses_with_branc0/repo/.git cat-file --batch
DEBUG    b4:__init__.py:3639 Running git --no-pager rev-parse --git-common-dir
DEBUG    b4:__init__.py:3639 Running git --no-pager rev-parse --show-toplevel
=========================== short test summary info ============================
FAILED src/tests/test_tui_tracking.py::TestTrackingMultipleSeries::test_mixed_statuses_with_branches
============ 1 failed, 1439 passed, 12 skipped in 482.06s (0:08:02) ============

[-- Attachment #4: i586-tui-tracking-footer-race.patch --]
[-- Type: text/x-patch, Size: 1664 bytes --]

--- a/src/b4/review_tui/_tracking_app.py	2026-11-09 10:07:09.098984181 +0100
+++ b/src/b4/review_tui/_tracking_app.py	2026-11-09 10:08:06.729134755 +0100
@@ -1303,12 +1303,22 @@
             ):
                 await widget.remove()
 
+            try:
+                footer = self.query_one(Footer)
+            except NoMatches:
+                # The widget.remove() calls above yield control back to the
+                # event loop; if the app is shutting down concurrently (e.g.
+                # a pending refresh racing app teardown), the Footer may
+                # already be gone by the time we get here. Nothing left to
+                # update.
+                return
+
             if not display_series:
                 empty = Static(
                     'No tracked series. Use "b4 review track" to add series.',
                     id='tracking-empty',
                 )
-                await self.mount(empty, before=self.query_one(Footer))
+                await self.mount(empty, before=footer)
                 return
 
             header_text = f'{"Submitter":<20s}{"A":>1s} {"A·R·T":>7s}  {"Msgs":>6s}  {"S":<4s}{"Subject"}'
@@ -1316,8 +1326,8 @@
 
             list_items: List[ListItem] = [TrackedSeriesItem(s) for s in display_series]
             lv = ReplacementListView(*list_items, id='tracking-list', scroll_y=scroll_y)
-            await self.mount(header, before=self.query_one(Footer))
-            await self.mount(lv, before=self.query_one(Footer))
+            await self.mount(header, before=footer)
+            await self.mount(lv, before=footer)
 
         new_index = 0
         if self._focus_change_id:

[-- Attachment #5: i586-tui-tracking-test-timing.patch --]
[-- Type: text/x-patch, Size: 1483 bytes --]

--- a/src/tests/test_tui_tracking.py	2026-08-05 18:36:05.000000000 +0200
+++ b/src/tests/test_tui_tracking.py	2026-11-06 07:29:06.052790975 +0100
@@ -2076,6 +2076,20 @@
         app = TrackingApp(identifier)
         async with app.run_test(size=(120, 30)) as pilot:
             await pilot.pause()
+            # pilot.pause() with no explicit delay relies on Textual's
+            # wait_for_idle() heuristic, which compares wall-clock time to
+            # this process's own CPU time to decide the app has settled.
+            # on_mount() here spawns several real `git` subprocesses (branch
+            # tip lookup, plus a background _startup_rescan worker thread)
+            # before the deferred _refresh_list() call actually mounts
+            # #tracking-list. While this process is blocked waiting for a
+            # forked git subprocess, it isn't burning its own CPU time, so
+            # the heuristic can decide "idle" before those subprocesses (and
+            # the resulting mount) have actually finished -- especially on
+            # slow/emulated builders where subprocess fork+exec overhead is
+            # much higher in wall-clock terms. An explicit extra pause makes
+            # the wait robust regardless of that heuristic.
+            await pilot.pause(0.5)
             lv = app.query_one('#tracking-list', ListView)
             items = [c for c in lv.children if isinstance(c, TrackedSeriesItem)]
             assert len(items) == 3

                 reply	other threads:[~2026-11-09  9:47 UTC|newest]

Thread overview: [no followups] expand[flat|nested]  mbox.gz  Atom feed

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=aa3c547b-daa6-4daf-9b64-cb5f10995b32@kernel.org \
    --to=jirislaby@kernel.org \
    --cc=tools@kernel.org \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox