|
1 | 1 | """Task-local command logs, runtime loader selection and compiler dependencies.""" |
2 | 2 | import hashlib |
| 3 | +import io |
3 | 4 | import json |
4 | 5 | import os |
5 | 6 | from pathlib import Path |
6 | 7 | import shlex |
7 | 8 | import signal |
8 | 9 | import subprocess |
| 10 | +import sys |
9 | 11 | import threading |
10 | 12 | import time |
11 | 13 |
|
@@ -123,28 +125,147 @@ def __init__(self, argv, cwd, env): |
123 | 125 | self.close() |
124 | 126 | raise |
125 | 127 |
|
126 | | - def terminate(self): |
| 128 | + def exited(self, deadline=None): |
| 129 | + if self.job: |
| 130 | + return self.child.poll() is not None |
| 131 | + return self._anchor(deadline) |
| 132 | + |
| 133 | + def _anchor(self, deadline=None): |
| 134 | + # Never reap the session leader before retirement: its kernel lifetime |
| 135 | + # reserves the numeric PID/PGID for every group signal and observation. |
| 136 | + if self.child.returncode is not None: |
| 137 | + raise RuntimeError('command process-group ownership lost: leader reaped') |
| 138 | + if callable(getattr(os, 'waitid', None)) and hasattr(os, 'WNOWAIT'): |
| 139 | + try: |
| 140 | + return os.waitid(os.P_PID, self.child.pid, |
| 141 | + os.WEXITED | os.WNOHANG | os.WNOWAIT) is not None |
| 142 | + except ChildProcessError as error: |
| 143 | + raise RuntimeError('command process-group ownership lost') from error |
| 144 | + members = _process_snapshot(time.monotonic() + 5 if deadline is None else deadline) |
| 145 | + leader = members.get(self.child.pid) |
| 146 | + self._check_leader(leader) |
| 147 | + return leader[2] == 'Z' |
| 148 | + |
| 149 | + def _check_leader(self, leader): |
| 150 | + if leader is None or leader[:2] != (os.getpid(), self.child.pid): |
| 151 | + raise RuntimeError('command process-group ownership lost: leader absent or changed') |
| 152 | + |
| 153 | + def _kill_group(self, deadline): |
| 154 | + _remaining(deadline) |
| 155 | + self._anchor(deadline) |
| 156 | + _remaining(deadline) |
| 157 | + try: |
| 158 | + os.killpg(self.child.pid, signal.SIGKILL) |
| 159 | + except ProcessLookupError: |
| 160 | + pass # The snapshot must still prove that no live member remains. |
| 161 | + |
| 162 | + def terminate(self, deadline=None): |
127 | 163 | if self.job: |
128 | 164 | self.job.terminate() |
129 | 165 | if self.child is not None and self.child.poll() is None: |
130 | 166 | self.child.kill() # Also covers a failure before job assignment. |
131 | | - elif self.child is not None: |
132 | | - try: |
133 | | - os.killpg(self.child.pid, signal.SIGKILL) |
134 | | - except ProcessLookupError: |
135 | | - pass |
136 | | - if self.child is not None: |
137 | | - self.child.wait(timeout=5) |
138 | | - if self.job: |
| 167 | + if self.child is not None: |
| 168 | + self.child.wait(timeout=5) |
139 | 169 | self.job.wait_empty() |
| 170 | + return |
| 171 | + if self.child is None: |
| 172 | + return |
| 173 | + deadline = deadline if deadline is not None else time.monotonic() + 5 |
| 174 | + retired = False |
| 175 | + try: |
| 176 | + self._kill_group(deadline) |
| 177 | + while True: |
| 178 | + _remaining(deadline) |
| 179 | + self._anchor(deadline) |
| 180 | + members = _process_snapshot(deadline) |
| 181 | + self._check_leader(members.get(self.child.pid)) |
| 182 | + live = any(group == self.child.pid and state != 'Z' |
| 183 | + for _, group, state in members.values()) |
| 184 | + if not live and retired: |
| 185 | + break |
| 186 | + retired = not live |
| 187 | + if live: |
| 188 | + self._kill_group(deadline) |
| 189 | + time.sleep(min(.01, _remaining(deadline))) |
| 190 | + finally: |
| 191 | + # Failure remains a failure; still reap our direct child if possible. |
| 192 | + self.child.wait(timeout=_remaining(deadline)) |
140 | 193 |
|
141 | 194 | def close(self): |
142 | 195 | if self.job: |
143 | 196 | self.job.close() |
144 | 197 |
|
145 | 198 |
|
146 | | -def _capture(pipe, output, done, errors): |
147 | | - remaining = OUTPUT_LIMIT |
| 199 | +def _remaining(deadline): |
| 200 | + remaining = deadline - time.monotonic() |
| 201 | + if remaining <= 0: |
| 202 | + raise RuntimeError('command cleanup deadline exceeded') |
| 203 | + return remaining |
| 204 | + |
| 205 | + |
| 206 | +def _process_snapshot(deadline): |
| 207 | + members = {} |
| 208 | + if sys.platform.startswith('linux'): |
| 209 | + for entry in Path('/proc').iterdir(): |
| 210 | + _remaining(deadline) |
| 211 | + if not entry.name.isascii() or not entry.name.isdigit(): |
| 212 | + continue |
| 213 | + try: |
| 214 | + data = (entry / 'stat').read_text() |
| 215 | + except (FileNotFoundError, ProcessLookupError): |
| 216 | + continue |
| 217 | + fields = data.rsplit(')', 1)[-1].split() |
| 218 | + if (len(fields) < 4 or len(fields[0]) != 1 or fields[0] not in 'RSDZTWtXxKIP' |
| 219 | + or not fields[1].isascii() or not fields[1].isdigit() |
| 220 | + or not fields[2].isascii() or not fields[2].isdigit() |
| 221 | + or data.split(' ', 1)[0] != entry.name or ')' not in data): |
| 222 | + raise RuntimeError('malformed command process snapshot') |
| 223 | + members[int(entry.name)] = (int(fields[1]), int(fields[2]), fields[0]) |
| 224 | + return members |
| 225 | + # macOS Python <3.13 has no waitid. Numeric state/parent/group fields keep |
| 226 | + # observation non-reaping without relying on coarse ps start-time strings. |
| 227 | + executable = next((path for path in ('/bin/ps', '/usr/bin/ps') if Path(path).is_file()), None) |
| 228 | + if executable is None: |
| 229 | + raise RuntimeError('command process snapshot unavailable: system ps missing') |
| 230 | + output, done, errors = io.BytesIO(), threading.Event(), [] |
| 231 | + probe = subprocess.Popen([executable, '-A', '-o', 'pid=', '-o', 'ppid=', '-o', 'pgid=', '-o', 'state='], |
| 232 | + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) |
| 233 | + reader = threading.Thread(target=_capture, args=(probe.stdout, output, done, errors, 4 * 1024 * 1024), daemon=True) |
| 234 | + reader.start() |
| 235 | + try: |
| 236 | + if not done.wait(_remaining(deadline)): |
| 237 | + raise RuntimeError('command process snapshot deadline exceeded') |
| 238 | + if errors: |
| 239 | + raise RuntimeError('command process snapshot failed: ' + str(errors[0])) |
| 240 | + if probe.wait(timeout=_remaining(deadline)): |
| 241 | + raise RuntimeError('command process snapshot ps failed') |
| 242 | + finally: |
| 243 | + if probe.poll() is None: |
| 244 | + probe.kill() |
| 245 | + try: |
| 246 | + probe.wait(timeout=max(0, deadline - time.monotonic())) |
| 247 | + finally: |
| 248 | + reader.join(timeout=max(0, deadline - time.monotonic())) |
| 249 | + if reader.is_alive(): |
| 250 | + raise RuntimeError('command process snapshot reader deadline exceeded') |
| 251 | + for line in output.getvalue().decode('ascii').splitlines(): |
| 252 | + _remaining(deadline) |
| 253 | + fields = line.split() |
| 254 | + if (len(fields) != 4 or any(not item.isdigit() for item in fields[:3]) |
| 255 | + or not fields[3] or fields[3][0] not in 'RSDZTWtXxKIPLU' |
| 256 | + or any(flag not in '+<>ACEJLNSsVWX' for flag in fields[3][1:])): |
| 257 | + raise RuntimeError('malformed command process snapshot') |
| 258 | + pid, parent, group = map(int, fields[:3]) |
| 259 | + if pid <= 0 or pid in members: |
| 260 | + raise RuntimeError('invalid command process snapshot PID') |
| 261 | + members[pid] = (parent, group, fields[3][0]) |
| 262 | + if not members: |
| 263 | + raise RuntimeError('empty command process snapshot') |
| 264 | + return members |
| 265 | + |
| 266 | + |
| 267 | +def _capture(pipe, output, done, errors, limit=OUTPUT_LIMIT): |
| 268 | + remaining = limit |
148 | 269 | try: |
149 | 270 | while chunk := os.read(pipe.fileno(), 65536): |
150 | 271 | output.write(chunk[:remaining]) |
@@ -182,26 +303,38 @@ def __call__(self, argv, cwd, env, timeout=1200, allow_failure=False): |
182 | 303 | try: |
183 | 304 | reader.start() |
184 | 305 | deadline = time.monotonic() + timeout |
185 | | - while child.poll() is None or not done.wait(.05): |
| 306 | + while not tree.exited(deadline) or not done.wait(.05): |
186 | 307 | if errors: |
187 | 308 | raise errors[0] |
188 | 309 | if time.monotonic() > deadline: |
189 | 310 | raise RuntimeError('command deadline/output budget exceeded') |
190 | 311 | time.sleep(.05) |
191 | 312 | if errors: |
192 | 313 | raise errors[0] |
193 | | - child.wait(timeout=5) |
194 | | - record['exit'] = child.returncode |
195 | | - except BaseException: |
196 | | - tree.terminate() |
197 | | - record['exit'] = child.returncode |
| 314 | + except BaseException as error: |
| 315 | + original_error = error |
198 | 316 | raise |
| 317 | + else: |
| 318 | + original_error = None |
199 | 319 | finally: |
200 | | - if reader.ident is not None: |
201 | | - reader.join(timeout=5) |
202 | | - tree.close() |
| 320 | + cleanup_deadline = time.monotonic() + 5 |
| 321 | + try: |
| 322 | + if tree.job and original_error is None: |
| 323 | + child.wait(timeout=5) |
| 324 | + else: |
| 325 | + tree.terminate(cleanup_deadline) |
| 326 | + except BaseException as cleanup_error: |
| 327 | + context = f'; original command failure: {original_error}' if original_error else '' |
| 328 | + raise RuntimeError(f'command cleanup failed: {cleanup_error}{context}') from cleanup_error |
| 329 | + finally: |
| 330 | + record['exit'] = child.returncode |
| 331 | + if reader.ident is not None: |
| 332 | + budget = 5 if tree.job else max(0, cleanup_deadline - time.monotonic()) |
| 333 | + reader.join(timeout=budget) |
| 334 | + tree.close() |
203 | 335 | if reader.is_alive(): |
204 | | - raise RuntimeError('command pipe cleanup deadline exceeded') |
| 336 | + context = f'; original command failure: {original_error}' if original_error else '' |
| 337 | + raise RuntimeError('command pipe cleanup deadline exceeded' + context) |
205 | 338 | if log.stat().st_size > OUTPUT_LIMIT: |
206 | 339 | raise RuntimeError('command deadline/output budget exceeded') |
207 | 340 | with log.open('rb') as output: |
|
0 commit comments