|
| 1 | +import os |
| 2 | +import sys |
| 3 | +import time |
1 | 4 | import asyncio |
2 | 5 | import datetime |
3 | 6 | import ipaddress |
@@ -1004,6 +1007,78 @@ async def test_run_in_executor_mp(helpers): |
1004 | 1007 | assert result == sum(range(50_000)) |
1005 | 1008 |
|
1006 | 1009 |
|
| 1010 | +@pytest.mark.skipif(not sys.platform.startswith("linux"), reason="PR_SET_PDEATHSIG is Linux-only") |
| 1011 | +def test_pool_workers_die_with_parent(): |
| 1012 | + """Pool workers must not survive when the parent is SIGKILL'd (OOM, crash, etc.).""" |
| 1013 | + import json |
| 1014 | + import signal |
| 1015 | + import subprocess |
| 1016 | + import tempfile |
| 1017 | + |
| 1018 | + script = """ |
| 1019 | +import os, sys, json, time, signal, ctypes, ctypes.util, multiprocessing as mp |
| 1020 | +from concurrent.futures import ProcessPoolExecutor |
| 1021 | +
|
| 1022 | +_PR_SET_PDEATHSIG = 1 |
| 1023 | +
|
| 1024 | +def _init(): |
| 1025 | + libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True) |
| 1026 | + libc.prctl(_PR_SET_PDEATHSIG, signal.SIGKILL, 0, 0, 0) |
| 1027 | +
|
| 1028 | +def _get_pid(): |
| 1029 | + time.sleep(1) |
| 1030 | + return os.getpid() |
| 1031 | +
|
| 1032 | +# use fork context explicitly -- forkserver on 3.14 adds an intermediary process |
| 1033 | +# that complicates the parent-death chain; PR_SET_PDEATHSIG itself is start-method-agnostic |
| 1034 | +ctx = mp.get_context("fork") |
| 1035 | +pool = ProcessPoolExecutor(max_workers=2, initializer=_init, mp_context=ctx) |
| 1036 | +# submit concurrently so both workers are occupied (each takes 1s) |
| 1037 | +futs = [pool.submit(_get_pid) for _ in range(2)] |
| 1038 | +pids = list(set(f.result(timeout=30) for f in futs)) |
| 1039 | +# keep workers busy so they stay alive |
| 1040 | +[pool.submit(time.sleep, 3600) for _ in range(2)] |
| 1041 | +print(json.dumps(pids), flush=True) |
| 1042 | +time.sleep(3600) |
| 1043 | +""" |
| 1044 | + with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f: |
| 1045 | + f.write(script) |
| 1046 | + script_path = f.name |
| 1047 | + |
| 1048 | + def _is_running(pid): |
| 1049 | + """Check /proc to distinguish running processes from zombies.""" |
| 1050 | + try: |
| 1051 | + with open(f"/proc/{pid}/stat") as f: |
| 1052 | + # format: "pid (comm) state ..." -- state after the last ')' |
| 1053 | + state = f.read().split(")")[-1].strip().split()[0] |
| 1054 | + return state not in ("Z", "X", "x") |
| 1055 | + except (OSError, IndexError): |
| 1056 | + return False |
| 1057 | + |
| 1058 | + try: |
| 1059 | + proc = subprocess.Popen([sys.executable, script_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE) |
| 1060 | + line = proc.stdout.readline() |
| 1061 | + assert line, f"Worker script exited early, stderr: {proc.stderr.read().decode()}" |
| 1062 | + worker_pids = json.loads(line) |
| 1063 | + assert len(worker_pids) >= 2 |
| 1064 | + |
| 1065 | + # simulate OOM kill |
| 1066 | + os.kill(proc.pid, signal.SIGKILL) |
| 1067 | + proc.wait() |
| 1068 | + |
| 1069 | + time.sleep(2) |
| 1070 | + |
| 1071 | + alive = [pid for pid in worker_pids if _is_running(pid)] |
| 1072 | + |
| 1073 | + # clean up survivors so they don't leak into other tests |
| 1074 | + for pid in alive: |
| 1075 | + os.kill(pid, signal.SIGKILL) |
| 1076 | + |
| 1077 | + assert not alive, f"Pool workers {alive} survived parent SIGKILL (zombie leak)" |
| 1078 | + finally: |
| 1079 | + os.unlink(script_path) |
| 1080 | + |
| 1081 | + |
1007 | 1082 | def test_simhash_similarity(helpers): |
1008 | 1083 | """Test SimHash helper with increasingly different HTML pages.""" |
1009 | 1084 |
|
|
0 commit comments