Textualize/textual · error · RuntimeError

Can't write to the stream after it has stopped.

Error message

Can't write to the stream after it has stopped.

What it means

A RuntimeError raised by MarkdownStream.write after the stream's _run loop has finished (self._stopped). MarkdownStream is a one-shot streaming renderer: once its worker task stops (e.g. after an exception or shutdown), appending more fragments is illegal.

Source

Thrown at src/textual/widgets/_markdown.py:85

        if self._task is None:
            self._task = asyncio.create_task(self._run())

    async def stop(self) -> None:
        """Stop the stream and await its finish."""
        if self._task is not None:
            self._task.cancel()
            await self._task
            self._task = None
            self._stopped = True

    async def write(self, markdown_fragment: str) -> None:
        """Append or enqueue a markdown fragment.

        Args:
            markdown_fragment: A string to append at the end of the document.
        """
        if self._stopped:
            raise RuntimeError("Can't write to the stream after it has stopped.")
        if not markdown_fragment:
            # Nothing to do for empty strings.
            return
        # Append the new fragment, and set an event to tell the _run loop to wake up
        self._pending.append(markdown_fragment)
        self._new_markup.set()
        # Allow the task to wake up and actually display the new markdown
        await asyncio.sleep(0)

    async def _run(self) -> None:
        """Run a task to append markdown fragments when available."""
        try:
            while await self._new_markup.wait():
                new_markdown = "".join(self._pending)
                self._pending.clear()
                self._new_markup.clear()
                await asyncio.shield(self.markdown_widget.append(new_markdown))
        except asyncio.CancelledError:

View on GitHub (pinned to 06dbeef4bb)

Solutions

  1. Create a fresh MarkdownStream per response/document rather than reusing one.
  2. Stop/cancel producer tasks when the stream ends, and guard writes: if not stream._stopped.
  3. Catch RuntimeError around write in retry/cancellation paths.
  4. Ensure the widget's on_unmount cancels the producer feeding the stream.

Example fix

# before
stream.write(chunk)  # may run after stream stopped

# after
try:
    stream.write(chunk)
except RuntimeError:
    stream = MarkdownStream()
    await mount(stream)
    stream.write(chunk)
Defensive patterns

Strategy: try-catch

Validate before calling

def safe_write(stream: MarkdownStream, fragment: str) -> bool:
    try:
        stream.write(fragment)
        return True
    except RuntimeError:
        return False

Try / catch

try:
    stream.write(chunk)
except RuntimeError:
    # stream finished; create a new one or stop producing
    producer.cancel()

Prevention

When it happens

Trigger: Writing to a MarkdownStream after its internal loop ended — e.g. after an exception in _run, after the app/widget is shutting down, or reusing a stream object for a second response.

Common situations: Streaming LLM/chat responses where a retry reuses the same MarkdownStream; writing from a background task that outlives the widget; not checking stream state on cancellation.

Related errors


AI-assisted analysis of Textualize/textual@06dbeef4bb (2026-08-27). Data as JSON: /api/errors/d1071b20b0ddd015. Report an issue: GitHub.