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)
_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.
- 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
Description
Since 0.86,
MatplotlibChartCanvasapplies frames over aDataChanneland_send_and_waitawaits Dart's[0xFF]frame-applied ack with no timeout(current
main):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 andsilently returns (
if self._closed: return), so nothing raises;channel dies.
The await therefore parks forever. The parked producer is
MatplotlibChart._receive_loop— the sole consumer of the frame queue — sothe chart freezes for the rest of the session. When the user switches back,
Dart opens a fresh channel and
_capture_channelswaps it in, but the stalefutures in
_pending_acksare never resolved, so the loop stays parked andframes pile up in
_receive_queueunconsumed. There is no exception, no logline, 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
MatplotlibChartCanvaswith a fake transport that mirrorsDataChannelsemantics exactly (silent drop when closed):repro_ack_deadlock.py(click to expand)Real-app reproduction: a Flet desktop app with two
MatplotlibCharttabsbeing 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)
_capture_channel: a freshly captured channel means every pending ackbelongs to a dead channel — resolve the stale futures so parked producers
resume immediately on remount.
FRAME_ACK_TIMEOUT = 5.0; a healthy ack lands inmilliseconds) 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
main(verified after Fix hidden-tab frame flood in RawImage and MatplotlibChart web animations (#6691) #6704:_capture_channeldoes not touch_pending_acks;_send_and_waithas no timeout on the ack await)_ProtocolMuxedDataChanneltransport)