Description
While command-palette providers are still yielding results, a keyboard selection can race a batched list refresh. The refresh clears and rebuilds CommandList and resets highlighted to 0, so Down can visibly highlight one command but a later provider result moves the highlight before Enter. The wrong command then runs.
I can reproduce this deterministically with Textual 8.2.8 on Python 3.12.11 by controlling the provider gates and the clock used for result batching.
Minimal reproduction
Save as repro.py and run it with Textual 8.2.8 (python repro.py):
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator, Callable
from textual.app import App, ComposeResult
from textual.command import CommandList, CommandPalette, Hit, Provider
from textual.widgets import Static
import textual.command
class Clock:
now = 0.0
def __call__(self) -> float:
return self.now
def advance(self) -> None:
self.now += 1.0
class Probe:
def __init__(self) -> None:
self.calls: list[str] = []
self.release_batch = asyncio.Event()
self.release_late = asyncio.Event()
self.batch_waiting = asyncio.Event()
self.late_waiting = asyncio.Event()
def callback(self, name: str) -> Callable[[], None]:
return lambda: self.calls.append(name)
probe = Probe()
class ControlledProvider(Provider):
async def search(self, query: str) -> AsyncIterator[Hit]:
if query != "logs":
return
yield Hit(0.90, "first", probe.callback("first"))
yield Hit(0.80, "second", probe.callback("second"))
probe.batch_waiting.set()
await probe.release_batch.wait()
yield Hit(0.70, "batch", probe.callback("batch"))
probe.late_waiting.set()
await probe.release_late.wait()
yield Hit(0.60, "late", probe.callback("late"))
class Repro(App[None]):
COMMANDS = {ControlledProvider}
def compose(self) -> ComposeResult:
yield Static("calling screen")
def on_mount(self) -> None:
self.push_screen(CommandPalette(id="--command-palette"))
async def wait_until(predicate, attempts: int = 50) -> None:
for _ in range(attempts):
if predicate():
return
await asyncio.sleep(0)
raise AssertionError("condition not reached")
async def main() -> None:
clock = Clock()
textual.command.monotonic = clock
app = Repro()
async with app.run_test() as pilot:
await pilot.press("l", "o", "g", "s")
await asyncio.wait_for(probe.batch_waiting.wait(), timeout=1)
clock.advance()
probe.release_batch.set()
commands = app.screen.query_one(CommandList)
await wait_until(lambda: commands.option_count == 3)
assert probe.late_waiting.is_set()
await pilot.press("down")
print("after Down:", commands.highlighted) # 1 (second)
clock.advance()
probe.release_late.set()
await wait_until(lambda: commands.option_count == 4)
print("after late refresh:", commands.highlighted) # 0 (first)
await pilot.press("enter")
await wait_until(lambda: probe.calls)
print("callback:", probe.calls)
asyncio.run(main())
Output:
after Down: 1
after late refresh: 0
callback: ['first']
Expected behavior
Once Down visibly moves the command-list highlight to second, pressing Enter should run second exactly once, even if another provider result arrives between those key presses.
Actual behavior
The late result causes _refresh_command_list() to call clear_options().add_options(...) and assign highlighted = 0. Enter consequently runs first exactly once, not the visibly selected second.
This deterministic reproducer confirms a wrong-command selection race. It does not reproduce the original live report in which the palette dismissed without running any command, so I am not claiming that narrower symptom here.
App-side workaround
As a narrow compatibility workaround, our app subclasses CommandPalette and overrides _action_command_list(). Before delegating any keyboard list action, it cancels the active gather worker only when the real CommandList is visible, contains at least one option, and the first option is not the disabled _NO_MATCHES placeholder. This freezes the actionable snapshot after navigation begins while still allowing initial results and replacement-query results to arrive.
The relevant protected seams are _action_command_list() and _cancel_gather_commands(); no provider behavior or timing constants are changed.
Description
While command-palette providers are still yielding results, a keyboard selection can race a batched list refresh. The refresh clears and rebuilds
CommandListand resetshighlightedto0, soDowncan visibly highlight one command but a later provider result moves the highlight beforeEnter. The wrong command then runs.I can reproduce this deterministically with Textual 8.2.8 on Python 3.12.11 by controlling the provider gates and the clock used for result batching.
Minimal reproduction
Save as
repro.pyand run it with Textual 8.2.8 (python repro.py):Output:
Expected behavior
Once
Downvisibly moves the command-list highlight tosecond, pressingEntershould runsecondexactly once, even if another provider result arrives between those key presses.Actual behavior
The late result causes
_refresh_command_list()to callclear_options().add_options(...)and assignhighlighted = 0.Enterconsequently runsfirstexactly once, not the visibly selectedsecond.This deterministic reproducer confirms a wrong-command selection race. It does not reproduce the original live report in which the palette dismissed without running any command, so I am not claiming that narrower symptom here.
App-side workaround
As a narrow compatibility workaround, our app subclasses
CommandPaletteand overrides_action_command_list(). Before delegating any keyboard list action, it cancels the active gather worker only when the realCommandListis visible, contains at least one option, and the first option is not the disabled_NO_MATCHESplaceholder. This freezes the actionable snapshot after navigation begins while still allowing initial results and replacement-query results to arrive.The relevant protected seams are
_action_command_list()and_cancel_gather_commands(); no provider behavior or timing constants are changed.