|
31 | 31 | from __future__ import annotations |
32 | 32 |
|
33 | 33 | import asyncio |
| 34 | +import concurrent.futures |
34 | 35 | import contextlib |
35 | 36 | import hashlib |
36 | 37 | import logging |
@@ -148,8 +149,7 @@ async def ensure(key: str, state_url: str) -> _Child: |
148 | 149 | child = children.get(key) |
149 | 150 | if child is not None and child.ready.is_set(): |
150 | 151 | return child |
151 | | - # The lock is held ONLY for the synchronous fork()+register, never the readiness wait — so a |
152 | | - # cold fork serializes other forks (fork-safety) but doesn't stall traffic to other children. |
| 152 | + # Readiness waits stay outside the lock, so traffic to ready children remains lock-free. |
153 | 153 | async with forking: |
154 | 154 | child = children.get(key) |
155 | 155 | creating = child is None |
@@ -177,15 +177,29 @@ async def ensure(key: str, state_url: str) -> _Child: |
177 | 177 | return child |
178 | 178 |
|
179 | 179 | async def reap(key: str) -> None: |
180 | | - child = children.pop(key, None) |
181 | | - if not child: |
182 | | - return |
183 | | - child.ready.set() # wake any waiter blocked in `ensure` — it re-checks `children` and re-forks |
184 | | - with contextlib.suppress(Exception): |
185 | | - os.kill(child.pid, signal.SIGKILL) |
186 | | - with contextlib.suppress(Exception): |
187 | | - os.waitpid(child.pid, 0) |
188 | | - shutil.rmtree(child.cwd, ignore_errors=True) |
| 180 | + # Keep fork() out of the cleanup thread's lifetime; ready children bypass this lock. |
| 181 | + async with forking: |
| 182 | + child = children.pop(key, None) |
| 183 | + if not child: |
| 184 | + return |
| 185 | + child.ready.set() # wake waiters; they re-check `children` before re-forking |
| 186 | + with contextlib.suppress(Exception): |
| 187 | + os.kill(child.pid, signal.SIGKILL) |
| 188 | + |
| 189 | + def cleanup() -> None: |
| 190 | + with contextlib.suppress(Exception): |
| 191 | + os.waitpid(child.pid, 0) |
| 192 | + shutil.rmtree(child.cwd, ignore_errors=True) |
| 193 | + |
| 194 | + # One worker keeps cleanup ordered and lets it finish if this task is cancelled. |
| 195 | + loop = asyncio.get_running_loop() |
| 196 | + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: |
| 197 | + future = loop.run_in_executor(executor, cleanup) |
| 198 | + try: |
| 199 | + await asyncio.shield(future) |
| 200 | + except asyncio.CancelledError: |
| 201 | + await future |
| 202 | + raise |
189 | 203 | logger.info("fork: reaped child pid=%d", child.pid) |
190 | 204 |
|
191 | 205 | async def _respond(send, status: int, body: bytes) -> None: |
|
0 commit comments