@@ -32,15 +32,24 @@ class StreamedLogBase:
3232 duration of the run (Impit currently maps it to an effective 24-hour cap) and mirrors the JS client.
3333 """
3434
35+ _empty_stream_retry_s : ClassVar [float ] = 0.5
36+ """Pause before reopening a log stream that ended before the run logged anything.
37+
38+ The API serves the log of a run that has not logged anything yet as an empty stream that ends at once.
39+ """
40+
3541 def __init__ (self , to_logger : logging .Logger , * , from_start : bool = True ) -> None :
3642 if self ._force_propagate :
3743 to_logger .propagate = True
3844 self ._to_logger = to_logger
3945 self ._stream_buffer = list [bytes ]()
4046 self ._split_marker = re .compile (rb'(?:\n|^)(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)' )
4147 self ._relevancy_time_limit : datetime | None = None if from_start else datetime .now (tz = UTC )
48+ self ._received_data = False
4249
4350 def _process_new_data (self , data : bytes ) -> None :
51+ if data :
52+ self ._received_data = True
4453 new_chunk = data
4554 self ._stream_buffer .append (new_chunk )
4655 if re .findall (self ._split_marker , new_chunk ):
@@ -75,6 +84,12 @@ def _log_buffer_content(self, *, include_last_part: bool = False) -> None:
7584 message = decoded_marker + decoded_content
7685 self ._to_logger .log (level = self ._guess_log_level_from_message (message ), msg = message .strip ())
7786
87+ def _process_whole_log (self , log : bytes | None ) -> None :
88+ """Redirect a log read in one request, for a stop that came before the stream delivered anything."""
89+ if log :
90+ self ._process_new_data (log )
91+ self ._log_buffer_content (include_last_part = True )
92+
7893 @staticmethod
7994 def _guess_log_level_from_message (message : str ) -> int :
8095 """Guess the log level from the message."""
@@ -121,6 +136,7 @@ def __init__(self, log_client: LogClient, *, to_logger: logging.Logger, from_sta
121136 self ._streaming_thread : Thread | None = None
122137 self ._log_stream : HttpResponse | None = None
123138 self ._stop_logging = False
139+ self ._stop_event = threading .Event ()
124140
125141 def start (self ) -> Thread :
126142 """Start the streaming thread.
@@ -130,6 +146,7 @@ def start(self) -> Thread:
130146 if self ._streaming_thread and self ._streaming_thread .is_alive ():
131147 raise RuntimeError ('Streaming thread already active' )
132148 self ._stop_logging = False
149+ self ._stop_event .clear ()
133150 # A daemon thread so a stream still blocked on a read can never hold up interpreter shutdown.
134151 self ._streaming_thread = threading .Thread (target = self ._stream_log , daemon = True )
135152 self ._streaming_thread .start ()
@@ -140,11 +157,13 @@ def stop(self) -> None:
140157
141158 A thread that outlives the wait is a daemon with `_stop_logging` set, so it exits after at most one more chunk,
142159 and only then does its buffered tail reach the logger. Its handle is kept while it is alive, so `start` cannot
143- revive it beside a second thread on the same buffer.
160+ revive it beside a second thread on the same buffer. If no stream has delivered anything yet, the thread reads
161+ the whole log in one request before it ends.
144162 """
145163 if not self ._streaming_thread :
146164 raise RuntimeError ('Streaming thread is not active' )
147165 self ._stop_logging = True
166+ self ._stop_event .set ()
148167 # Read once; the streaming thread clears the attribute as soon as the stream ends.
149168 log_stream = self ._log_stream
150169 if log_stream is not None :
@@ -173,39 +192,57 @@ def __exit__(
173192
174193 def _stream_log (self ) -> None :
175194 try :
176- with self ._log_client .stream (raw = True , timeout = self ._stream_timeout ) as log_stream :
177- if not log_stream :
195+ # An empty stream means the run has not logged anything yet, so reopen it until the first bytes arrive.
196+ while not self ._stop_logging :
197+ if not self ._stream_log_once () or self ._received_data :
178198 return
179- # Published so `stop` can close the response.
180- self ._log_stream = log_stream
181- try :
182- # `stop` may have run before the response existed for it to close.
183- if self ._stop_logging :
184- return
185- for data in log_stream .iter_bytes ():
186- self ._process_new_data (data )
187- if self ._stop_logging :
188- break
189- finally :
190- self ._log_stream = None
191- try :
192- # Flush the last buffered part even if the read timed out or was stopped.
193- self ._log_buffer_content (include_last_part = True )
194- except Exception :
195- # A truncated stream leaves an undecodable tail, which is worth a traceback even while a stop
196- # is in progress.
197- self ._to_logger .exception ('Log redirection stopped due to unexpected error:' )
199+ self ._stop_event .wait (self ._empty_stream_retry_s )
198200 except Exception as exc :
199201 if self ._stop_logging :
200202 # `stop` closed the stream out from under the read, so the failure is expected.
201203 self ._to_logger .debug ('Log streaming stopped while `stop` was in progress: %r' , exc )
202- return
203- if self ._log_client ._http_client .is_timeout_error (exc ): # noqa: SLF001
204+ elif self ._log_client ._http_client .is_timeout_error (exc ): # noqa: SLF001
204205 # The stream cannot continue, so warn and let the thread end instead of leaking a traceback.
205206 self ._to_logger .warning ('Log streaming stopped: the log stream request timed out.' )
207+ return
206208 else :
207209 # Any other failure in log redirection must not escape the background thread; log it instead.
208210 self ._to_logger .exception ('Log redirection stopped due to unexpected error:' )
211+ return
212+ if self ._received_data :
213+ return
214+ # Stopped before any stream delivered a byte, which a run that finishes quickly can cause.
215+ try :
216+ self ._process_whole_log (self ._log_client .get_as_bytes (raw = True ))
217+ except Exception :
218+ self ._to_logger .exception ('Log redirection stopped due to unexpected error:' )
219+
220+ def _stream_log_once (self ) -> bool :
221+ """Redirect one log stream until it ends or `stop` is called. Return `False` when the log does not exist."""
222+ with self ._log_client .stream (raw = True , timeout = self ._stream_timeout ) as log_stream :
223+ if not log_stream :
224+ return False
225+ # Published so `stop` can close the response.
226+ self ._log_stream = log_stream
227+ try :
228+ # `stop` may have run before the response existed for it to close. A stream opened this late would
229+ # end after its first chunk, so the whole log is read in one request instead.
230+ if self ._stop_logging :
231+ return True
232+ for data in log_stream .iter_bytes ():
233+ self ._process_new_data (data )
234+ if self ._stop_logging :
235+ break
236+ finally :
237+ self ._log_stream = None
238+ try :
239+ # Flush the last buffered part even if the read timed out or was stopped.
240+ self ._log_buffer_content (include_last_part = True )
241+ except Exception :
242+ # A truncated stream leaves an undecodable tail, which is worth a traceback even while a stop is
243+ # in progress.
244+ self ._to_logger .exception ('Log redirection stopped due to unexpected error:' )
245+ return True
209246
210247
211248@docs_group ('Other' )
@@ -244,17 +281,28 @@ def start(self) -> Task:
244281 return self ._streaming_task
245282
246283 async def stop (self ) -> None :
247- """Stop the streaming task."""
284+ """Stop the streaming task.
285+
286+ If no stream has delivered anything yet, read the whole log in one request instead.
287+ """
248288 if not self ._streaming_task :
249289 raise RuntimeError ('Streaming task is not active' )
250290
291+ was_streaming = not self ._streaming_task .done ()
251292 self ._streaming_task .cancel ()
252293 try :
253294 await self ._streaming_task
254295 except asyncio .CancelledError :
255296 pass
256297 finally :
257298 self ._streaming_task = None
299+ if not was_streaming or self ._received_data :
300+ return
301+ # Stopped before any stream delivered a byte, which a run that finishes quickly can cause.
302+ try :
303+ self ._process_whole_log (await self ._log_client .get_as_bytes (raw = True ))
304+ except Exception :
305+ self ._to_logger .exception ('Log redirection stopped due to unexpected error:' )
258306
259307 async def __aenter__ (self ) -> Self :
260308 """Start the streaming task within the context. Exiting the context will cancel the streaming task."""
@@ -269,20 +317,25 @@ async def __aexit__(
269317
270318 async def _stream_log (self ) -> None :
271319 try :
272- async with self ._log_client .stream (raw = True , timeout = self ._stream_timeout ) as log_stream :
273- if not log_stream :
274- return
275- try :
276- async for data in log_stream .aiter_bytes ():
277- self ._process_new_data (data )
278- finally :
320+ # An empty stream means the run has not logged anything yet, so reopen it until the first bytes arrive.
321+ while True :
322+ async with self ._log_client .stream (raw = True , timeout = self ._stream_timeout ) as log_stream :
323+ if not log_stream :
324+ return
279325 try :
280- # Flush the last buffered part even if the task is cancelled by `stop()`.
281- self ._log_buffer_content (include_last_part = True )
282- except Exception :
283- # A truncated stream leaves an undecodable tail. Keeping the failure here also keeps the
284- # cancellation `stop` raised propagating, so the task ends up cancelled as asyncio expects.
285- self ._to_logger .exception ('Log redirection stopped due to unexpected error:' )
326+ async for data in log_stream .aiter_bytes ():
327+ self ._process_new_data (data )
328+ finally :
329+ try :
330+ # Flush the last buffered part even if the task is cancelled by `stop()`.
331+ self ._log_buffer_content (include_last_part = True )
332+ except Exception :
333+ # A truncated stream leaves an undecodable tail. Keeping the failure here also keeps the
334+ # cancellation `stop` raised propagating, so the task ends up cancelled as asyncio expects.
335+ self ._to_logger .exception ('Log redirection stopped due to unexpected error:' )
336+ if self ._received_data :
337+ return
338+ await asyncio .sleep (self ._empty_stream_retry_s )
286339 except Exception as exc :
287340 if self ._log_client ._http_client .is_timeout_error (exc ): # noqa: SLF001
288341 # A timeout on the long-lived stream is an expected terminal condition, not an error.
0 commit comments