|
30 | 30 | tee = shutil.which("tee") |
31 | 31 |
|
32 | 32 |
|
| 33 | +async def _wait_for_file_to_exist(file_path: str, timeout: float = 5.0) -> None: |
| 34 | + """Wait for a file to exist and be non-empty. |
| 35 | +
|
| 36 | + Uses condition-based waiting instead of arbitrary sleep to eliminate |
| 37 | + race conditions in tests that verify child processes have started |
| 38 | + writing to marker files. |
| 39 | +
|
| 40 | + Args: |
| 41 | + file_path: Path to the file to wait for |
| 42 | + timeout: Maximum time to wait in seconds |
| 43 | +
|
| 44 | + Raises: |
| 45 | + TimeoutError: If file doesn't exist or remains empty after timeout |
| 46 | + """ |
| 47 | + start_time = time.time() |
| 48 | + poll_interval = 0.01 # Poll every 10ms |
| 49 | + |
| 50 | + while time.time() - start_time < timeout: |
| 51 | + if os.path.exists(file_path): |
| 52 | + size = os.path.getsize(file_path) |
| 53 | + if size > 0: |
| 54 | + return |
| 55 | + await anyio.sleep(poll_interval) |
| 56 | + |
| 57 | + # Check one more time for better error message |
| 58 | + if os.path.exists(file_path): |
| 59 | + size = os.path.getsize(file_path) |
| 60 | + raise TimeoutError(f"File {file_path} exists but is empty after {timeout}s (size={size})") |
| 61 | + else: |
| 62 | + raise TimeoutError(f"File {file_path} does not exist after {timeout}s") |
| 63 | + |
| 64 | + |
33 | 65 | @pytest.mark.anyio |
34 | 66 | @pytest.mark.skipif(tee is None, reason="could not find tee command") |
35 | 67 | async def test_stdio_context_manager_exiting(): |
@@ -296,19 +328,15 @@ async def test_basic_child_process_cleanup(self): |
296 | 328 | # Start the parent process |
297 | 329 | proc = await _create_platform_compatible_process(sys.executable, ["-c", parent_script]) |
298 | 330 |
|
299 | | - # Wait for processes to start |
300 | | - await anyio.sleep(0.5) |
301 | | - |
302 | | - # Verify parent started |
| 331 | + # Wait for parent to start (condition-based) |
| 332 | + with anyio.fail_after(5.0): |
| 333 | + await _wait_for_file_to_exist(parent_marker) |
303 | 334 | assert os.path.exists(parent_marker), "Parent process didn't start" |
304 | 335 |
|
305 | | - # Verify child is writing |
306 | | - if os.path.exists(marker_file): # pragma: no branch |
307 | | - initial_size = os.path.getsize(marker_file) |
308 | | - await anyio.sleep(0.3) |
309 | | - size_after_wait = os.path.getsize(marker_file) |
310 | | - assert size_after_wait > initial_size, "Child process should be writing" |
311 | | - print(f"Child is writing (file grew from {initial_size} to {size_after_wait} bytes)") |
| 336 | + # Wait for child to start writing (condition-based instead of arbitrary 0.5s + 0.3s) |
| 337 | + with anyio.fail_after(5.0): |
| 338 | + await _wait_for_file_to_exist(marker_file) |
| 339 | + print(f"Child is writing (file size: {os.path.getsize(marker_file)} bytes)") |
312 | 340 |
|
313 | 341 | # Terminate using our function |
314 | 342 | print("Terminating process and children...") |
@@ -398,16 +426,15 @@ async def test_nested_process_tree(self): |
398 | 426 | # Start the parent process |
399 | 427 | proc = await _create_platform_compatible_process(sys.executable, ["-c", parent_script]) |
400 | 428 |
|
401 | | - # Let all processes start |
402 | | - await anyio.sleep(1.0) |
403 | | - |
404 | | - # Verify all are writing |
| 429 | + # Wait for all processes to start (condition-based) |
405 | 430 | for file_path, name in [(parent_file, "parent"), (child_file, "child"), (grandchild_file, "grandchild")]: |
406 | | - if os.path.exists(file_path): # pragma: no branch |
407 | | - initial_size = os.path.getsize(file_path) |
408 | | - await anyio.sleep(0.3) |
409 | | - new_size = os.path.getsize(file_path) |
410 | | - assert new_size > initial_size, f"{name} process should be writing" |
| 431 | + with anyio.fail_after(5.0): |
| 432 | + await _wait_for_file_to_exist(file_path) |
| 433 | + |
| 434 | + parent_size = os.path.getsize(parent_file) |
| 435 | + child_size = os.path.getsize(child_file) |
| 436 | + grandchild_size = os.path.getsize(grandchild_file) |
| 437 | + print(f"parent={parent_size}, child={child_size}, grandchild={grandchild_size}") |
411 | 438 |
|
412 | 439 | # Terminate the whole tree |
413 | 440 | await _terminate_process_tree(proc) |
@@ -477,15 +504,10 @@ def handle_term(sig, frame): |
477 | 504 | # Start the parent process |
478 | 505 | proc = await _create_platform_compatible_process(sys.executable, ["-c", parent_script]) |
479 | 506 |
|
480 | | - # Let child start writing |
481 | | - await anyio.sleep(0.5) |
482 | | - |
483 | | - # Verify child is writing |
484 | | - if os.path.exists(marker_file): # pragma: no branch |
485 | | - size1 = os.path.getsize(marker_file) |
486 | | - await anyio.sleep(0.3) |
487 | | - size2 = os.path.getsize(marker_file) |
488 | | - assert size2 > size1, "Child should be writing" |
| 507 | + # Wait for child to start writing (condition-based) |
| 508 | + with anyio.fail_after(5.0): |
| 509 | + await _wait_for_file_to_exist(marker_file) |
| 510 | + print(f"Child is writing (file size: {os.path.getsize(marker_file)} bytes)") |
489 | 511 |
|
490 | 512 | # Terminate - this will kill the process group even if parent exits first |
491 | 513 | await _terminate_process_tree(proc) |
|
0 commit comments