Skip to content

Select can crash during mount: SelectCurrent.update() queries #label before it is mounted (NoMatches) #6581

Description

@henryiii

The bug

I've optimized uproot-browser recently (using Claude Fable during the brief window it was available), and it works really well, but there's an occasional crash on CI - always on Windows. I've looked into it, and put the summary below - the best way to reduce the issue seems to go back to not lazy-loading the tools tab anymore (in scikit-hep/uproot-browser#249). It's a pretty rare crash, about 1/3 of the time and there are several windows jobs, so maybe 1/9? I think it's due to speed, though, not Windows specific, as Claude was able to make a MWE that I can see crash.

Happy to try applying this as a PR if you want me to.

🤖 Claude Opus issue report 🤖

Title: Select can crash during mount: SelectCurrent.update() queries #label before it is mounted (NoMatches)

Textual version: 8.2.7
Platform: Windows (GitHub Actions windows-latest), Python 3.10/3.12 — intermittent, timing-dependent

Description

Select._on_mount eagerly initializes the value, which cascades into a query for a child that may not be mounted yet, crashing the mount:

Select._on_mount
  → _init_selected_option(self._value)
    → self.value = hint
      → Select._watch_value
        → SelectCurrent.update(prompt)
          → self.query_one("#label", Static)   # NoMatches

Select._watch_value defensively guards the outer lookup:

try:
    select_current = self.query_one(SelectCurrent)
except NoMatches:
    pass

…but SelectCurrent.update() itself does an unguarded inner query:

def update(self, label: RenderableType | NoSelection) -> None:
    self.label = label
    self.has_value = label is not Select.NULL
    self.query_one("#label", Static).update(...)   # crashes if #label not mounted yet

So when SelectCurrent is mounted but its own composed children (#label, from SelectCurrent.compose) are not yet, update() raises NoMatches and the Select fails to finish mounting (its value stays Select.NULL).

Traceback (abridged)

.../textual/widgets/_select.py:629 in _on_mount
    self._init_selected_option(self._value)
.../textual/widgets/_select.py:557 in _init_selected_option
    self.value = hint                       # hint = 'textual-dark'
.../textual/widgets/_select.py:615 in _watch_value
    select_current.update(prompt)
.../textual/widgets/_select.py:256 in update
    self.query_one("#label", Static).update(...)
.../textual/dom.py:1505: NoMatches
NoMatches: No nodes match '#label' on SelectCurrent(classes='-has-value')

When it shows up

Intermittently on slow CI runners, and notably when the Select is mounted via textual.lazy.Lazy(...), which changes mount ordering. (allow_blank=False with an explicit value= guarantees _init_selected_option sets a value during mount, so the path is always exercised.)

Suggested fix

Make SelectCurrent.update() resilient to its children not yet being mounted — either guard the #label lookup the same way _watch_value guards SelectCurrent, or defer the update until after SelectCurrent has fully mounted. For example:

def update(self, label: RenderableType | NoSelection) -> None:
    self.label = label
    self.has_value = label is not Select.NULL
    try:
        label_widget = self.query_one("#label", Static)
    except NoMatches:
        return  # not mounted yet; _on_mount will render the initial value
    label_widget.update(
        self.placeholder if isinstance(label, NoSelection) else label
    )

This happens on Windows in CI, so I didn't include textual diagnose output.

🤖 MWE 🤖 (verified with a manual run too)

"""MWE: Textual `Select` crashes during mount with `NoMatches` on '#label'.

`Select._watch_value` guards `query_one(SelectCurrent)` with try/except NoMatches,
but `SelectCurrent.update()` then does an UNGUARDED `query_one("#label", Static)`.
There is a window during mount where `SelectCurrent` is already mounted (so the
guard passes) yet its composed '#label' child is not mounted yet. The value-init
in `Select._on_mount` lands in that window and crashes:

    Select._on_mount
      -> _init_selected_option(self._value)   # allow_blank=False + value
        -> self.value = ...
          -> Select._watch_value              # query_one(SelectCurrent) OK
            -> SelectCurrent.update(...)
              -> self.query_one("#label", Static)   # NoMatches  <-- bug

In real apps this is an intermittent race on slow runners (seen on Windows CI,
and made more likely by mounting the Select via `textual.lazy.Lazy`). Here it is
made deterministic by deferring ONLY the '#label' child's mount by one refresh,
so `Select._on_mount` reliably falls inside the window. Everything else is stock.

Tested with Textual 8.2.7. Run with `python textual_select_mount_mwe.py`; it
exits with the NoMatches traceback above (remove SlowSelect to confirm the
stock Select usually does NOT crash on a fast machine).
"""

import textual.app
import textual.widgets as w
from textual.widgets._select import NonSelectableStatic, SelectCurrent, SelectOverlay


class SlowSelectCurrent(SelectCurrent):
    """Stock SelectCurrent, but '#label' mounts one refresh late."""

    def compose(self):
        yield NonSelectableStatic("▼", classes="arrow down-arrow")
        yield NonSelectableStatic("▲", classes="arrow up-arrow")

    def on_mount(self):
        # SelectCurrent is mounted now (the _watch_value guard will pass), but
        # '#label' is still pending -> reproduces the slow-runner ordering.
        self.call_after_refresh(
            self.mount, NonSelectableStatic(self.placeholder, id="label")
        )


class SlowSelect(w.Select):
    def compose(self):
        yield SlowSelectCurrent(self.prompt)
        yield SelectOverlay(type_to_search=self._type_to_search).data_bind(
            compact=w.Select.compact
        )


class MWEApp(textual.app.App):
    def compose(self):
        yield SlowSelect([("a", "a"), ("b", "b")], allow_blank=False, value="a")


if __name__ == "__main__":
    MWEApp().run()

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions