Skip to content

bug: Cosmetic tracebacks on exit (/exit, Ctrl+C, Ctrl+D) due to Textual widget teardown race conditions #929

Description

@pcx-wave

Component

CLI

Summary

See Vibe (mistral medium 3.5) + Claude sonnet 5 report :

Description

When exiting Vibe's Textual UI (via /exit, Ctrl+C, or Ctrl+D), the app closes correctly but prints tracebacks to stderr. This appears purely cosmetic — the process exits fine — but is confusing for users. Root cause: async event handlers in vibe/cli/textual_ui/app.py keep running after self.exiting is set to True and Textual has started tearing down widgets, so callbacks end up querying/mounting widgets that no longer exist or aren't attached to the DOM yet.

Steps to Reproduce

  1. Start Vibe (vibe --mode cli or the default TUI)
  2. Type /exit and press Enter — or press Ctrl+C / Ctrl+D during input
  3. Observe stderr after the app closes

Expected Behavior

Clean exit, no traceback printed.

Actual Behavior

Up to 5 distinct tracebacks depending on the exit path:

1) on_chat_input_container_submitted (app.py:983)

vibe/cli/textual_ui/app.py:983 in on_chat_input_container_submitted
    await self._dispatch_idle_input(value)

Queries ChatInputContainer after it has already been destroyed.

2) _dispatch_idle_input (app.py:1007)

vibe/cli/textual_ui/app.py:1007 in _dispatch_idle_input
    await self._handle_command(value)

Dispatches a command while self.exiting == True.

3) _handle_command (app.py:1766)

vibe/cli/textual_ui/app.py:1766 in _handle_command
    await self._mount_and_scroll(SlashCommandMessage(display))

Mounts a SlashCommandMessage for /exit right before _exit_app() tears down the UI.

4) _mount_and_scroll (app.py:4400)

vibe/cli/textual_ui/app.py:4400 in _mount_and_scroll
    await messages_area.mount(widget)

Mounts a widget into messages_area while self.exiting == True.

5) Textual MountError (textual/widget.py:1454)

textual/widget.py:1454 in mount
    raise MountError(f"Can't mount widget(s) before {self!r} is mounted")

Mounts into messages_area before it is attached to the Textual DOM (race during init/teardown).

Environment

  • Vibe: 2.21.0
  • Python: 3.11.2
  • OS: Linux aarch64 (Raspberry Pi, Debian-based, kernel 6.12.93+rpt-rpi-v8)

Suggested Fix

Four small guards in app.py (~15 lines total) eliminate all 5 tracebacks:

  • if self.exiting: return at the top of on_chat_input_container_submitted, _dispatch_idle_input, and _mount_and_scroll
  • catch NoMatches (not a bare Exception) around self.query_one(ChatInputContainer)
  • check messages_area.is_attached before mounting in _mount_and_scroll
  • skip mounting SlashCommandMessage when command.exits is True (no need to display a message right before the app closes)

### Reproduction steps

just exiting the cli

### Versions / environment

Vibe: 2.21.0, Python: 3.11.2, OS: Linux aarch64 (Raspberry Pi, Debian-based, kernel 6.12.93+rpt-rpi-v8)

### Logs & screenshots

```shell
╭────────────────────────────────────────────────────────────────────────────────────── Traceback (most recent call last) ──────────────────────────────────────────────────────────────────────────────────────╮
│ /home/pcx-pi/.local/share/uv/tools/mistral-vibe/lib/python3.12/site-packages/vibe/cli/textual_ui/app.py:983 in on_chat_input_container_submitted                                                              │
│                                                                                                                                                                                                               │
│    980 │   │   │   │   self._restore_input_if_empty(input_widget, value)                                                                                                                                      │
│    981 │   │   │   return                                                                                                                                                                                     │
│    982 │   │                                                                                                                                                                                                  │
│ ❱  983 │   │   await self._dispatch_idle_input(value)                                                                                                                                                         │
│    984 │                                                                                                                                                                                                      │
│    985 │   @staticmethod                                                                                                                                                                                      │
│    986 │   def _restore_input_if_empty(input_widget: ChatInputContainer, value: str) -> None:                                                                                                                 │
│                                                                                                                                                                                                               │
│ ╭─────────────────────────────────────────────────────────── locals ────────────────────────────────────────────────────────────╮                                                                             │
│ │        event = Submitted()                                                                                                    │                                                                             │
│ │ input_widget = ChatInputContainer(id='input-container')                                                                       │                                                                             │
│ │         self = VibeApp(title='VibeApp', classes={'-theme-ansi-dark', '-dark-mode'}, pseudo_classes={'focus', 'ansi', 'dark'}) │                                                                             │
│ │        value = '/exit'                                                                                                        │                                                                             │
│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯                                                                             │
│                                                                                                                                                                                                               │
│ /home/pcx-pi/.local/share/uv/tools/mistral-vibe/lib/python3.12/site-packages/vibe/cli/textual_ui/app.py:1007 in _dispatch_idle_input                                                                          │
│                                                                                                                                                                                                               │
│   1004 │   │   │   case Teleport(target=target):                                                                                                                                                              │
│   1005 │   │   │   │   await self._handle_teleport_command(target)                                                                                                                                            │
│   1006 │   │   │   case SlashCommand():                                                                                                                                                                       │
│ ❱ 1007 │   │   │   │   await self._handle_command(value)                                                                                                                                                      │
│   1008 │   │   │   case Skill(command=command, name=name):                                                                                                                                                    │
│   1009 │   │   │   │   self._send_skill_telemetry(name)                                                                                                                                                       │
│   1010 │   │   │   │   await self._handle_user_message(command, title_source=command)                                                                                                                         │
│                                                                                                                                                                                                               │
│ ╭──────────────────────────────────────────────────────── locals ────────────────────────────────────────────────────────╮                                                                                    │
│ │  self = VibeApp(title='VibeApp', classes={'-theme-ansi-dark', '-dark-mode'}, pseudo_classes={'focus', 'ansi', 'dark'}) │                                                                                    │
│ │ value = '/exit'                                                                                                        │                                                                                    │
│ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯                                                                                    │
│                                                                                                                                                                                                               │
│ /home/pcx-pi/.local/share/uv/tools/mistral-vibe/lib/python3.12/site-packages/vibe/cli/textual_ui/app.py:1766 in _handle_command                                                                               │
│                                                                                                                                                                                                               │
│   1763 │   │   │   │   if command_text.startswith("/")                                                                                                                                                        │
│   1764 │   │   │   │   else cmd_name                                                                                                                                                                          │
│   1765 │   │   │   )                                                                                                                                                                                          │
│ ❱ 1766 │   │   │   await self._mount_and_scroll(SlashCommandMessage(display))                                                                                                                                 │
│   1767 │   │   │   handler = getattr(self, command.handler)                                                                                                                                                   │
│   1768 │   │   │   if asyncio.iscoroutinefunction(handler):                                                                                                                                                   │
│   1769 │   │   │   │   await handler(cmd_args=cmd_args)                                                                                                                                                       │
│                                                                                                                                                                                                               │
│ ╭───────────────────────────────────────────────────────────────────────────────────────── locals ──────────────────────────────────────────────────────────────────────────────────────────╮                 │
│ │     cmd_args = ''                                                                                                                                                                         │                 │
│ │     cmd_name = 'exit'                                                                                                                                                                     │                 │
│ │      command = Command(aliases=frozenset({':q', '/exit', 'exit', ':quit', 'quit'}), description='Exit the application', handler='_exit_app', exits=True, is_available=None)               │                 │
│ │ command_text = '/exit'                                                                                                                                                                    │                 │
│ │      display = 'exit'                                                                                                                                                                     │                 │
│ │     resolved = ('exit', Command(aliases=frozenset({':q', '/exit', 'exit', ':quit', 'quit'}), description='Exit the application', handler='_exit_app', exits=True, is_available=None), '') │                 │
│ │         self = VibeApp(title='VibeApp', classes={'-theme-ansi-dark', '-dark-mode'}, pseudo_classes={'focus', 'ansi', 'dark'})                                                             │                 │
│ │   user_input = '/exit'                                                                                                                                                                    │                 │
│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯                 │
│                                                                                                                                                                                                               │
│ /home/pcx-pi/.local/share/uv/tools/mistral-vibe/lib/python3.12/site-packages/vibe/cli/textual_ui/app.py:4400 in _mount_and_scroll                                                                             │
│                                                                                                                                                                                                               │
│   4397 │   │   │   elif pin_anchor is not None:                                                                                                                                                               │
│   4398 │   │   │   │   await messages_area.mount(widget, before=pin_anchor)                                                                                                                                   │
│   4399 │   │   │   else:                                                                                                                                                                                      │
│ ❱ 4400 │   │   │   │   await messages_area.mount(widget)                                                                                                                                                      │
│   4401 │   │   │   if isinstance(widget, StreamingMessageBase):                                                                                                                                               │
│   4402 │   │   │   │   await widget.write_initial_content()                                                                                                                                                   │
│   4403                                                                                                                                                                                                        │
│                                                                                                                                                                                                               │
│ ╭────────────────────────────────────────────────────────────── locals ──────────────────────────────────────────────────────────────╮                                                                        │
│ │             after = None                                                                                                           │                                                                        │
│ │            before = None                                                                                                           │                                                                        │
│ │ is_user_initiated = True                                                                                                           │                                                                        │
│ │     messages_area = VerticalGroup(id='messages')                                                                                   │                                                                        │
│ │        pin_anchor = None                                                                                                           │                                                                        │
│ │              self = VibeApp(title='VibeApp', classes={'-theme-ansi-dark', '-dark-mode'}, pseudo_classes={'focus', 'ansi', 'dark'}) │                                                                        │
│ │     should_anchor = True                                                                                                           │                                                                        │
│ │            widget = SlashCommandMessage(classes='user-message slash-command-message')                                              │                                                                        │
│ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯                                                                        │
│                                                                                                                                                                                                               │
│ /home/pcx-pi/.local/share/uv/tools/mistral-vibe/lib/python3.12/site-packages/textual/widget.py:1454 in mount                                                                                                  │
│                                                                                                                                                                                                               │
│   1451 │   │   if self._closing or self._pruning:                                               ╭──────────────────────────────────── locals ────────────────────────────────────╮                            │
│   1452 │   │   │   return AwaitMount(self, [])                                                  │   after = None                                                                 │                            │
│   1453 │   │   if not self.is_attached:                                                         │  before = None                                                                 │                            │
│ ❱ 1454 │   │   │   raise MountError(f"Can't mount widget(s) before {self!r} is mounted")        │    self = VerticalGroup(id='messages')                                         │                            │
│   1455 │   │   # Check for duplicate IDs in the incoming widgets                                │ widgets = (SlashCommandMessage(classes='user-message slash-command-message'),) │                            │
│   1456 │   │   ids_to_mount = [                                                                 ╰────────────────────────────────────────────────────────────────────────────────╯                            │
│   1457 │   │   │   widget_id for widget in widgets if (widget_id := widget.id) is not None                                                                                                                    │
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
MountError: Can't mount widget(s) before VerticalGroup(id='messages') is mounted

Metadata

Metadata

Assignees

No one assigned

    Labels

    CLIRelated to CLIbugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions