Skip to content

Commit 1e730f1

Browse files
authored
Stream dispatch_checks output when running a single check (#47214)
* test * test2 * add header in stream output for vnext checks * minor typing * more specific exception and logging * no try except
1 parent f9fbe94 commit 1e730f1

1 file changed

Lines changed: 151 additions & 39 deletions

File tree

eng/scripts/dispatch_checks.py

Lines changed: 151 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
80122
def _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}\nCMD: {' '.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"\nTotal checks: {len(results)} | Failed: {len(failed)} | Worst exit code: {worst}")
326+
print(
327+
f"\nTotal 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

Comments
 (0)