Skip to content

MatplotlibChart stops rendering permanently after tab switch — frame-ack await deadlocks _receive_loop #6709

Description

@ForsakenDurian

Description

Since 0.86, MatplotlibChartCanvas applies frames over a DataChannel and
_send_and_wait awaits Dart's [0xFF] frame-applied ack with no timeout
(current main):

async def _send_and_wait(self, packet: bytes) -> None:
    await self._wait_until_visible()
    if self._channel is None:
        return
    loop = asyncio.get_running_loop()
    fut: asyncio.Future = loop.create_future()
    self._pending_acks.append(fut)
    self._channel.send(packet)
    await fut          # <- no timeout

When the widget's platform view is disposed while a frame is in flight —
the common trigger is the user switching to another tab, which makes Flutter
dispose the view and its channel — the ack never arrives:

  • DataChannel.send() on a closed channel is documented fire-and-forget and
    silently returns (if self._closed: return), so nothing raises;
  • nothing on the Python side fails or resolves the pending futures when the
    channel dies.

The await therefore parks forever. The parked producer is
MatplotlibChart._receive_loop — the sole consumer of the frame queue — so
the chart freezes for the rest of the session. When the user switches back,
Dart opens a fresh channel and _capture_channel swaps it in, but the stale
futures in _pending_acks are never resolved, so the loop stays parked and
frames pile up in _receive_queue unconsumed. There is no exception, no log
line, no recovery.

This is adjacent to, but distinct from, #6691: the wait_until_visible()
gate added there parks the producer deliberately while the app is hidden and
releases it on resume. Here the producer parks on an ack that can never
arrive, and nothing ever releases it — in-app tab switches dispose the
platform view without the app ever becoming "hidden", so the visibility gate
doesn't help.

Reproduction

Deterministic, no GUI required — script below.
It drives the real MatplotlibChartCanvas with a fake transport that mirrors
DataChannel semantics exactly (silent drop when closed):

repro_ack_deadlock.py (click to expand)
"""Standalone repro: flet-charts 0.86 frame-ack deadlock (and the fix).

MatplotlibChartCanvas._send_and_wait awaits Dart's [0xFF] frame-applied ack
with no timeout. When the widget's platform view is disposed with a frame in
flight (e.g. the user switches tabs — Dart tears down the view and its
DataChannel), the ack never arrives: DataChannel.send() on a closed channel
is documented fire-and-forget and silently returns. The await then parks the
producer FOREVER. In production that producer is MatplotlibChart._receive_loop,
the sole consumer of the frame queue — so the chart freezes for the rest of
the session. Remounting opens a fresh channel, but _capture_channel never
resolves the stale futures in _pending_acks, so nothing ever recovers.

Usage:
    python repro_ack_deadlock.py                 # against installed flet-charts: deadlock
    python repro_ack_deadlock.py --fixed [PATH]  # against a patched matplotlib_chart_canvas.py
                                                 # (default: upstream/matplotlib_chart_canvas.fixed.py;
                                                 #  pass the file from the PR branch to verify it)

Only the transport is faked, matching flet.data_channel's real semantics.
"""

import asyncio
import importlib.util
import pathlib
import sys


def load_canvas_class(fixed_path):
    if fixed_path is None:
        from flet_charts.matplotlib_chart_canvas import MatplotlibChartCanvas
        return MatplotlibChartCanvas
    path = pathlib.Path(fixed_path)
    spec = importlib.util.spec_from_file_location("mpl_canvas_fixed", path)
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod.MatplotlibChartCanvas


class FakeChannel:
    """Mirrors flet's _ProtocolMuxedDataChannel: fire-and-forget send that
    silently drops when closed; acks delivered back via the canvas handler."""

    def __init__(self, canvas):
        self.canvas = canvas
        self.closed = False
        self.sent = 0

    def on_bytes(self, handler):
        pass  # the canvas already routes acks through _on_dart_message

    def send(self, payload: bytes):
        if self.closed:
            return  # exact library behavior: silent drop, no raise
        self.sent += 1
        asyncio.get_running_loop().call_soon(self._ack)

    def _ack(self):
        if self.closed:
            return
        self.canvas._on_dart_message(b"\xff")


class DyingChannel(FakeChannel):
    """The dispose race: the packet leaves, the view is torn down before the
    ack returns."""

    def send(self, payload: bytes):
        self.sent += 1
        self.closed = True


async def main(fixed_path):
    MatplotlibChartCanvas = load_canvas_class(fixed_path)
    canvas = MatplotlibChartCanvas()
    canvas.init()

    applied = 0

    async def producer():
        # Stands in for MatplotlibChart._receive_loop: one frame at a time,
        # each awaiting its ack.
        nonlocal applied
        for _ in range(5):
            await canvas.apply_full(b"png-frame")
            applied += 1

    canvas._channel = DyingChannel(canvas)  # first send loses its ack
    task = asyncio.create_task(producer())
    await asyncio.sleep(0.1)
    print(f"after in-flight frame lost its ack: applied={applied} "
          f"(producer {'parked' if applied == 0 else 'running'})")

    # Widget remounts: Dart opens a fresh, healthy channel.
    live = FakeChannel(canvas)
    canvas.get_data_channel = lambda cid: live

    class Ev:
        channel_id = 2

    canvas._capture_channel(Ev())
    await asyncio.sleep(0.3)

    if applied == 5:
        print(f"RECOVERED: all 5 frames applied after remount "
              f"(sent_on_new_channel={live.sent})")
    else:
        print(f"DEADLOCK: producer still parked after remount — applied={applied}, "
              f"frames never reach the new channel (sent={live.sent}). "
              f"In production the chart is now frozen for the session.")
    task.cancel()
    return applied == 5


if __name__ == "__main__":
    fixed_path = None
    if "--fixed" in sys.argv:
        i = sys.argv.index("--fixed")
        fixed_path = (
            sys.argv[i + 1]
            if len(sys.argv) > i + 1
            else pathlib.Path(__file__).parent / "upstream" / "matplotlib_chart_canvas.fixed.py"
        )
    ok = asyncio.run(main(fixed_path))
    sys.exit(0 if ok else 1)
after in-flight frame lost its ack: applied=0 (producer parked)
DEADLOCK: producer still parked after remount — applied=0, frames never
reach the new channel (sent=0). In production the chart is now frozen for
the session.

Real-app reproduction: a Flet desktop app with two MatplotlibChart tabs
being switched ~1×/second freezes a chart within about a minute (each switch
races the 1 Hz frame push; a frame mid-round-trip at dispose time loses its
ack). Observed on flet/flet-charts 0.86.1, Linux desktop, muxed socket
transport; both charts in the app eventually froze this way.

Expected behavior

A lost frame ack should cost at most one dropped frame (the next draw
repaints anyway), never the rest of the session.

Suggested fix (PR to follow)

  1. _capture_channel: a freshly captured channel means every pending ack
    belongs to a dead channel — resolve the stale futures so parked producers
    resume immediately on remount.
  2. Bound the ack await (FRAME_ACK_TIMEOUT = 5.0; a healthy ack lands in
    milliseconds) so a producer can't park forever even if no remount ever
    comes. On timeout the future must also be removed from _pending_acks,
    since acks resolve the FIFO head — leaving it queued would misalign every
    subsequent ack.

Environment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions