@@ -166,7 +166,12 @@ def __init__(self, on_flush: Callable[[StreamTaskMessageDelta], Awaitable[object
166166 self ._first_flushed = False
167167 self ._closed = False
168168 self ._lock = asyncio .Lock ()
169- self ._flush_signal = asyncio .Event ()
169+ # Two events so the ticker can park at zero CPU when idle:
170+ # _wake — buffer went empty -> non-empty; the ticker should run
171+ # _flush_now — flush immediately (first delta / size threshold / close),
172+ # bypassing the coalescing window
173+ self ._wake = asyncio .Event ()
174+ self ._flush_now = asyncio .Event ()
170175 self ._task : asyncio .Task [None ] | None = None
171176
172177 def start (self ) -> None :
@@ -177,22 +182,42 @@ async def add(self, update: StreamTaskMessageDelta) -> None:
177182 if self ._closed :
178183 return
179184 async with self ._lock :
185+ was_empty = not self ._buf
180186 self ._buf .append (update )
181187 self ._buf_chars += _delta_char_len (update .delta )
182188 if not self ._first_flushed or self ._buf_chars >= self .MAX_BUFFERED_CHARS :
183189 self ._first_flushed = True
184- self ._flush_signal .set ()
190+ self ._flush_now .set ()
191+ # Wake the (possibly parked) ticker when the buffer goes from empty
192+ # to non-empty; it then applies the coalescing window itself.
193+ if was_empty :
194+ self ._wake .set ()
185195
186196 async def _run (self ) -> None :
187197 try :
188198 while True :
189- try :
190- await asyncio .wait_for (self ._flush_signal .wait (), timeout = self .FLUSH_INTERVAL_S )
191- except asyncio .TimeoutError :
192- pass
199+ # Park at zero CPU until there is data to flush (or close()).
200+ # This is the key change from a fixed-interval ticker: an idle
201+ # or orphaned buffer blocks here instead of waking every
202+ # FLUSH_INTERVAL_S forever — the latter leaked CPU when a buffer
203+ # outlived its stream without close() running (one spinning task
204+ # per such stream).
205+ await self ._wake .wait ()
206+ self ._wake .clear ()
207+ # First delta / size threshold / close flush immediately;
208+ # otherwise coalesce for up to FLUSH_INTERVAL_S so consecutive
209+ # deltas batch into a single publish.
210+ if not self ._flush_now .is_set () and not self ._closed :
211+ try :
212+ await asyncio .wait_for (self ._flush_now .wait (), timeout = self .FLUSH_INTERVAL_S )
213+ except asyncio .TimeoutError :
214+ pass
193215 async with self ._lock :
194- self ._flush_signal .clear ()
216+ self ._flush_now .clear ()
195217 drained = self ._drain_locked ()
218+ # Data that arrived during the flush keeps the ticker running.
219+ if self ._buf :
220+ self ._wake .set ()
196221 for u in drained :
197222 try :
198223 await self ._on_flush (u )
@@ -215,12 +240,17 @@ async def close(self) -> None:
215240 # producing the duplicate-tail symptom seen on the UI stream.
216241 self ._closed = True
217242 if self ._task is not None :
218- self ._flush_signal .set ()
243+ # Wake the parked ticker so it sees _closed and exits after its
244+ # next drain.
245+ self ._wake .set ()
246+ self ._flush_now .set ()
219247 try :
220248 await self ._task
221249 except asyncio .CancelledError :
222- # Propagate if our caller is being cancelled; the task itself
223- # swallows CancelledError so this only fires on outer cancel.
250+ # Our caller is being cancelled. Force-cancel the ticker so it
251+ # can never be orphaned into a parked/looping task, then
252+ # propagate the cancellation.
253+ self ._task .cancel ()
224254 raise
225255 self ._task = None
226256 async with self ._lock :
0 commit comments