@@ -77,6 +77,48 @@ def _normalize_newlines(text: str) -> str:
7777 return text .replace ("\r \n " , "\n " ).replace ("\r " , "\n " )
7878
7979
80+ async def _tee_stream (
81+ proc : "asyncio.subprocess.Process" , package : str , check : str
82+ ) -> tuple :
83+ """Read the child's stdout and stderr concurrently, mirroring each line to
84+ this process's stdout/stderr while also accumulating the full text for the
85+ final :class:`CheckResult`.
86+
87+ This exists because the default ``proc.communicate()`` path buffers all
88+ output until the child exits. When a check hangs and the pipeline agent
89+ cancels the parent at the job timeout, none of that buffered output is
90+ ever printed -- the log shows ``CMD: ...`` followed by silence until the
91+ cancellation marker. Streaming line-by-line ensures the last bytes the
92+ child produced before getting stuck are visible in the pipeline log.
93+
94+ Should only be used when checks are running sequentially (``max_parallel
95+ == 1``) -- otherwise output from concurrent children will interleave.
96+ """
97+ prefix = f"[{ os .path .basename (os .path .normpath (package ))} :: { check } ] "
98+
99+ async def _pump (stream : Optional [asyncio .StreamReader ], sink : IO [str ]) -> str :
100+ if stream is None :
101+ return ""
102+ chunks : List [str ] = []
103+ while True :
104+ line_b = await stream .readline ()
105+ if not line_b :
106+ break
107+ line = line_b .decode (errors = "replace" )
108+ chunks .append (line )
109+ sink .write (prefix + line )
110+ sink .flush ()
111+ return "" .join (chunks )
112+
113+ stdout_text , stderr_text = await asyncio .gather (
114+ _pump (proc .stdout , sys .stdout ),
115+ _pump (proc .stderr , sys .stderr ),
116+ )
117+ # Make sure the process is reaped so returncode is populated.
118+ await proc .wait ()
119+ return stdout_text , stderr_text
120+
121+
80122def _checks_require_recording_restore (checks : List [str ]) -> bool :
81123 return any (check in INSTALL_AND_TEST_CHECKS for check in checks )
82124
@@ -87,7 +129,9 @@ def _compare_req_to_injected_reqs(parsed_req, injected_packages: List[str]) -> b
87129 return any (parsed_req .name in req for req in injected_packages )
88130
89131
90- def _inject_custom_reqs (req_file : str , injected_packages : str , package_dir : str ) -> None :
132+ def _inject_custom_reqs (
133+ req_file : str , injected_packages : str , package_dir : str
134+ ) -> None :
91135 req_lines = []
92136 injected_list = [p for p in re .split (r"[\s,]" , injected_packages ) if p ]
93137
@@ -116,7 +160,8 @@ def _inject_custom_reqs(req_file: str, injected_packages: str, package_dir: str)
116160 all_adjustments = installable + [
117161 line_tuple [0 ].strip ()
118162 for line_tuple in req_lines
119- if line_tuple [0 ].strip () and not _compare_req_to_injected_reqs (line_tuple [1 ], all_filter_names )
163+ if line_tuple [0 ].strip ()
164+ and not _compare_req_to_injected_reqs (line_tuple [1 ], all_filter_names )
120165 ]
121166 else :
122167 all_adjustments = installable
@@ -139,6 +184,7 @@ async def run_check(
139184 dest_dir : Optional [str ] = None ,
140185 service : Optional [str ] = None ,
141186 python_version : Optional [str ] = None ,
187+ stream_live : bool = False ,
142188) -> CheckResult :
143189 """Run a single check (subprocess) within a concurrency semaphore, capturing output and timing.
144190
@@ -156,6 +202,12 @@ async def run_check(
156202 :type total: int
157203 :param proxy_port: Dedicated proxy port assigned to this check instance.
158204 :type proxy_port: int
205+ :param stream_live: If True, tee the child's stdout/stderr to this process's
206+ stdout/stderr line-by-line while also capturing them. Use only when
207+ checks run sequentially (``max_parallel == 1``) to avoid interleaved
208+ output. This prevents silent hangs from hiding all diagnostics when the
209+ agent kills the parent before the child exits.
210+ :type stream_live: bool
159211 :returns: A :class:`CheckResult` describing exit code, duration and captured output.
160212 :rtype: CheckResult
161213 """
@@ -173,9 +225,16 @@ async def run_check(
173225 logger .info (f"[START { idx } /{ total } ] { check } :: { package } \n CMD: { ' ' .join (cmd )} " )
174226 env = os .environ .copy ()
175227 env ["PROXY_URL" ] = f"http://localhost:{ proxy_port } "
228+ # Force the child Python process to use unbuffered stdio. Without this,
229+ # the child's output can sit in its own internal buffers for minutes
230+ # (or never appear at all if the agent kills us at the job timeout),
231+ # making hung checks look like total silence in the pipeline log.
232+ env ["PYTHONUNBUFFERED" ] = "1"
176233
177234 if in_ci ():
178- env ["PROXY_ASSETS_FOLDER" ] = os .path .join (root_dir , ".assets_distributed" , str (proxy_port ))
235+ env ["PROXY_ASSETS_FOLDER" ] = os .path .join (
236+ root_dir , ".assets_distributed" , str (proxy_port )
237+ )
179238 try :
180239 logger .info (" " .join (cmd ))
181240 proc = await asyncio .create_subprocess_exec (
@@ -189,30 +248,43 @@ async def run_check(
189248 logger .error (f"Failed to start check { check } for { package } : { ex } " )
190249 return CheckResult (package , check , 127 , 0.0 , "" , str (ex ))
191250
192- stdout_b , stderr_b = await proc .communicate ()
251+ if stream_live :
252+ if in_ci ():
253+ print (f"##[group]{ package } :: { check } :: ?" )
254+ stdout , stderr = await _tee_stream (proc , package , check )
255+ if in_ci ():
256+ print ("##[endgroup]" )
257+ else :
258+ stdout_b , stderr_b = await proc .communicate ()
259+ stdout = stdout_b .decode (errors = "replace" )
260+ stderr = stderr_b .decode (errors = "replace" )
193261 duration = time .time () - start
194- stdout = stdout_b .decode (errors = "replace" )
195- stderr = stderr_b .decode (errors = "replace" )
196262 exit_code = proc .returncode or 0
197263 status = "OK" if exit_code == 0 else f"FAIL({ exit_code } )"
198- logger .info (f"[END { idx } /{ total } ] { check } :: { package } -> { status } in { duration :.2f} s" )
199- # Print captured output after completion to avoid interleaving
200- header = f"===== OUTPUT: { check } :: { package } (exit { exit_code } ) ====="
201- trailer = "=" * len (header )
202- if in_ci ():
203- print (f"##[group]{ package } :: { check } :: { exit_code } " )
204-
205- if stdout :
206- print (header )
207- print (_normalize_newlines (stdout ).rstrip ())
208- print (trailer )
209- if stderr :
210- print (header .replace ("OUTPUT" , "STDERR" ))
211- print (_normalize_newlines (stderr ).rstrip ())
212- print (trailer )
213-
214- if in_ci ():
215- print ("##[endgroup]" )
264+ logger .info (
265+ f"[END { idx } /{ total } ] { check } :: { package } -> { status } in { duration :.2f} s"
266+ )
267+ # When streaming live we've already mirrored every line to the parent's
268+ # stdout/stderr as it was produced, so skip the post-hoc grouped dump
269+ # to avoid duplicating every line.
270+ if not stream_live :
271+ # Print captured output after completion to avoid interleaving
272+ header = f"===== OUTPUT: { check } :: { package } (exit { exit_code } ) ====="
273+ trailer = "=" * len (header )
274+ if in_ci ():
275+ print (f"##[group]{ package } :: { check } :: { exit_code } " )
276+
277+ if stdout :
278+ print (header )
279+ print (_normalize_newlines (stdout ).rstrip ())
280+ print (trailer )
281+ if stderr :
282+ print (header .replace ("OUTPUT" , "STDERR" ))
283+ print (_normalize_newlines (stderr ).rstrip ())
284+ print (trailer )
285+
286+ if in_ci ():
287+ print ("##[endgroup]" )
216288
217289 # if we have any output collections to complete, do so now here
218290
@@ -246,10 +318,14 @@ def summarize(results: List[CheckResult]) -> int:
246318 print ("-" * len (header ))
247319 for r in sorted (results , key = lambda x : (x .exit_code != 0 , x .package , x .check )):
248320 status = "OK" if r .exit_code == 0 else f"FAIL({ r .exit_code } )"
249- print (f"{ r .package .ljust (pkg_w )} { r .check .ljust (chk_w )} { status .ljust (8 )} { r .duration :>10.2f} " )
321+ print (
322+ f"{ r .package .ljust (pkg_w )} { r .check .ljust (chk_w )} { status .ljust (8 )} { r .duration :>10.2f} "
323+ )
250324 worst = max ((r .exit_code for r in results ), default = 0 )
251325 failed = [r for r in results if r .exit_code != 0 ]
252- print (f"\n Total checks: { len (results )} | Failed: { len (failed )} | Worst exit code: { worst } " )
326+ print (
327+ f"\n Total checks: { len (results )} | Failed: { len (failed )} | Worst exit code: { worst } "
328+ )
253329 return worst
254330
255331
@@ -287,10 +363,14 @@ async def run_all_checks(
287363 dependency_tools_path = os .path .join (root_dir , "eng" , "dependency_tools.txt" )
288364
289365 if in_ci ():
290- logger .info ("Replacing relative requirements in eng/test_tools.txt with prebuilt wheels." )
366+ logger .info (
367+ "Replacing relative requirements in eng/test_tools.txt with prebuilt wheels."
368+ )
291369 replace_dev_reqs (test_tools_path , root_dir , wheel_dir )
292370
293- logger .info ("Replacing relative requirements in eng/dependency_tools.txt with prebuilt wheels." )
371+ logger .info (
372+ "Replacing relative requirements in eng/dependency_tools.txt with prebuilt wheels."
373+ )
294374 replace_dev_reqs (dependency_tools_path , root_dir , wheel_dir )
295375
296376 for pkg in packages :
@@ -312,19 +392,34 @@ async def run_all_checks(
312392 if not is_check_enabled (package , check , CHECK_DEFAULTS .get (check , True )):
313393 logger .warning (f"Skipping disabled check { check } for package { package } " )
314394 continue
315- logger .info (f"Assigning proxy port { next_proxy_port } to check { check } for package { package } " )
395+ logger .info (
396+ f"Assigning proxy port { next_proxy_port } to check { check } for package { package } "
397+ )
316398
317399 # Check if this package overrides the Python version for analysis
318400 pkg_python_version = get_config_setting (package , "analyze_python_version" , None )
319401 if pkg_python_version :
320- logger .info (f"Package { package } overrides analyze Python version to { pkg_python_version } " )
402+ logger .info (
403+ f"Package { package } overrides analyze Python version to { pkg_python_version } "
404+ )
321405
322406 scheduled .append ((package , check , next_proxy_port , pkg_python_version ))
323407 next_proxy_port += 1
324408
325409 total = len (scheduled )
326410
327- for idx , (package , check , proxy_port , pkg_python_version ) in enumerate (scheduled , start = 1 ):
411+ # Mirror the child's stdio live to the parent's stdout/stderr when no
412+ # concurrent children could interleave output. This makes hangs visible
413+ # in real time instead of being hidden behind a post-hoc grouped dump
414+ # that never prints if the agent cancels us at the job timeout. We check
415+ # both the semaphore cap AND the actual number of tasks because cosmos
416+ # (and similar live test legs) ship a single check per matrix leg but
417+ # leave --max-parallel at its CPU-count default.
418+ stream_live = max_parallel == 1 or total <= 1
419+
420+ for idx , (package , check , proxy_port , pkg_python_version ) in enumerate (
421+ scheduled , start = 1
422+ ):
328423 tasks .append (
329424 asyncio .create_task (
330425 run_check (
@@ -339,6 +434,7 @@ async def run_all_checks(
339434 dest_dir ,
340435 service ,
341436 pkg_python_version ,
437+ stream_live = stream_live ,
342438 )
343439 )
344440 )
@@ -360,7 +456,9 @@ async def run_all_checks(
360456 elif isinstance (res , Exception ):
361457 norm_results .append (CheckResult (package , check , 99 , 0.0 , "" , str (res )))
362458 else :
363- norm_results .append (CheckResult (package , check , 98 , 0.0 , "" , f"Unknown result type: { res } " ))
459+ norm_results .append (
460+ CheckResult (package , check , 98 , 0.0 , "" , f"Unknown result type: { res } " )
461+ )
364462 return summarize (norm_results )
365463
366464
@@ -436,11 +534,15 @@ def handler(signum, frame):
436534 ),
437535 )
438536
439- parser .add_argument ("--disablecov" , help = ("Flag. Disables code coverage." ), action = "store_true" )
537+ parser .add_argument (
538+ "--disablecov" , help = ("Flag. Disables code coverage." ), action = "store_true"
539+ )
440540
441541 parser .add_argument (
442542 "--service" ,
443- help = ("Name of service directory (under sdk/) to test. Example: --service applicationinsights" ),
543+ help = (
544+ "Name of service directory (under sdk/) to test. Example: --service applicationinsights"
545+ ),
444546 )
445547
446548 parser .add_argument (
@@ -507,7 +609,9 @@ def handler(signum, frame):
507609 else :
508610 target_dir = root_dir
509611
510- logger .info (f"Beginning discovery for { args .service } and root dir { root_dir } . Resolving to { target_dir } ." )
612+ logger .info (
613+ f"Beginning discovery for { args .service } and root dir { root_dir } . Resolving to { target_dir } ."
614+ )
511615
512616 # ensure that recursive virtual envs aren't messed with by this call
513617 os .environ .pop ("VIRTUAL_ENV" , None )
@@ -524,7 +628,9 @@ def handler(signum, frame):
524628 )
525629
526630 if len (targeted_packages ) == 0 :
527- logger .info (f"No packages collected for targeting string { args .glob_string } and root dir { root_dir } . Exit 0." )
631+ logger .info (
632+ f"No packages collected for targeting string { args .glob_string } and root dir { root_dir } . Exit 0."
633+ )
528634 exit (0 )
529635
530636 logger .info (f"Executing checks with the executable { sys .executable } ." )
@@ -556,7 +662,9 @@ def handler(signum, frame):
556662 try :
557663 proxy_executable = prepare_local_tool (root_dir )
558664 except Exception as exc :
559- logger .error (f"Unable to prepare test proxy executable for recording restore: { exc } " )
665+ logger .error (
666+ f"Unable to prepare test proxy executable for recording restore: { exc } "
667+ )
560668 sys .exit (1 )
561669
562670 logger .info (
@@ -567,9 +675,13 @@ def handler(signum, frame):
567675 proxy_processes : List [ProxyProcess ] = []
568676 try :
569677 if in_ci ():
570- logger .info (f"Ensuring { len (checks )} test proxies are running for requested checks..." )
678+ logger .info (
679+ f"Ensuring { len (checks )} test proxies are running for requested checks..."
680+ )
571681 # Pass through service if set and not "auto"
572- effective_service = args .service if (args .service and args .service != "auto" ) else None
682+ effective_service = (
683+ args .service if (args .service and args .service != "auto" ) else None
684+ )
573685 exit_code = asyncio .run (
574686 run_all_checks (
575687 targeted_packages ,
0 commit comments