Skip to content

Commit e561520

Browse files
committed
Merge FEATURE-gate-investigation: fix process retirement and RSS accounting
Retire owned command descendants before returning and sample precise Linux RSS without changing the HTTP/3 memory bound. Complete checkout/archive consumer suites and the full HTTP/3 matrix pass; Groundwork validation passed with historical findings reconciled.
2 parents ad6a5a2 + 9a08510 commit e561520

6 files changed

Lines changed: 441 additions & 23 deletions

‎scripts/lib/package_commands.py‎

Lines changed: 154 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
"""Task-local command logs, runtime loader selection and compiler dependencies."""
22
import hashlib
3+
import io
34
import json
45
import os
56
from pathlib import Path
67
import shlex
78
import signal
89
import subprocess
10+
import sys
911
import threading
1012
import time
1113

@@ -123,28 +125,147 @@ def __init__(self, argv, cwd, env):
123125
self.close()
124126
raise
125127

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):
127163
if self.job:
128164
self.job.terminate()
129165
if self.child is not None and self.child.poll() is None:
130166
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)
139169
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))
140193

141194
def close(self):
142195
if self.job:
143196
self.job.close()
144197

145198

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
148269
try:
149270
while chunk := os.read(pipe.fileno(), 65536):
150271
output.write(chunk[:remaining])
@@ -182,26 +303,38 @@ def __call__(self, argv, cwd, env, timeout=1200, allow_failure=False):
182303
try:
183304
reader.start()
184305
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):
186307
if errors:
187308
raise errors[0]
188309
if time.monotonic() > deadline:
189310
raise RuntimeError('command deadline/output budget exceeded')
190311
time.sleep(.05)
191312
if errors:
192313
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
198316
raise
317+
else:
318+
original_error = None
199319
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()
203335
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)
205338
if log.stat().st_size > OUTPUT_LIMIT:
206339
raise RuntimeError('command deadline/output budget exceeded')
207340
with log.open('rb') as output:

‎scripts/lib/resource_limits_probe.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,13 @@ def measure(pid):
3232
# comm may contain spaces or parentheses; fields after its final ')' are stable.
3333
fields = Path(f'/proc/{pid}/stat').read_text().rsplit(')', 1)[1].split()
3434
cpu = (int(fields[11]) + int(fields[12])) / os.sysconf('SC_CLK_TCK')
35-
rss = int(Path(f'/proc/{pid}/statm').read_text().split()[1]) * os.sysconf('SC_PAGE_SIZE')
35+
# statm/status RSS counters are asynchronous; rollup walks the resident maps.
36+
rows = [line.split() for line in Path(f'/proc/{pid}/smaps_rollup').read_text().splitlines()
37+
if line.split() and line.split()[0] == 'Rss:']
38+
if (len(rows) != 1 or len(rows[0]) != 3 or rows[0][2] != 'kB'
39+
or not rows[0][1].isascii() or not rows[0][1].isdigit()):
40+
raise ValueError(f'invalid precise RSS in /proc/{pid}/smaps_rollup')
41+
rss = int(rows[0][1]) * 1024
3642
return cpu, rss
3743

3844

‎scripts/lib/resource_limits_probe_test.py‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
#!/usr/bin/env python3
22
"""Task-local plateau receipt checks; no network dependencies required."""
33
import unittest
4-
from resource_limits_probe import validate_window
4+
from pathlib import Path
5+
from unittest.mock import patch
6+
from resource_limits_probe import measure, validate_window
57

68
class Windows(unittest.TestCase):
79
def test_idle_spin_is_rejected(self):
@@ -15,5 +17,51 @@ def test_current_memory_growth_is_rejected(self):
1517
def test_bounded_idle_window_is_accepted(self):
1618
validate_window({'cpu_seconds': .1, 'wall_seconds': 1, 'rss_before': 1048576, 'rss_after': 1048576})
1719

20+
class Sampler(unittest.TestCase):
21+
def read_pair(self, raw, precise):
22+
fields = ['S'] + ['0'] * 10 + ['17', '23'] + ['0'] * 10
23+
def read(path, *args, **kwargs):
24+
if path.name == 'stat':
25+
return '123 (name with ) parentheses) ' + ' '.join(fields)
26+
if path.name == 'statm':
27+
return f'9000 {raw // 4096} 0 0 0 0 0'
28+
if path.name == 'smaps_rollup':
29+
if isinstance(precise, Exception): raise precise
30+
return precise if isinstance(precise, str) else f'Rss: {precise // 1024} kB\n'
31+
raise AssertionError(path)
32+
with patch.object(Path, 'read_text', read):
33+
return measure(123)
34+
35+
def test_captured_exact_bound_is_accepted_despite_approximate_growth(self):
36+
before = self.read_pair(19529728, 20054016)
37+
after = self.read_pair(20840448, 21102592)
38+
validate_window(dict(cpu_seconds=after[0]-before[0], wall_seconds=1,
39+
rss_before=before[1], rss_after=after[1]))
40+
self.assertEqual(1048576, after[1] - before[1])
41+
42+
def test_precise_overbound_still_fails(self):
43+
before = self.read_pair(19529728, 20054016)
44+
after = self.read_pair(19529728, 20054016 + 1048576 + 4096)
45+
with self.assertRaises(AssertionError):
46+
validate_window(dict(cpu_seconds=0, wall_seconds=1,
47+
rss_before=before[1], rss_after=after[1]))
48+
49+
def test_missing_or_invalid_precision_fails_explicitly(self):
50+
for value in (FileNotFoundError('missing rollup'), PermissionError('denied'), '',
51+
'Rss: -1 kB\n', 'Rss: x kB\n', 'Rss: 1 MB\n',
52+
'Rss: 1 kB extra\n', 'Rss: 1 kB\nRss: 2 kB\n'):
53+
with self.subTest(value=value), self.assertRaises((OSError, ValueError)):
54+
self.read_pair(19529728, value)
55+
56+
def test_cpu_fields_after_parenthesized_comm_are_unchanged(self):
57+
import os
58+
cpu, rss = self.read_pair(19529728, 20054016)
59+
self.assertEqual(40 / os.sysconf('SC_CLK_TCK'), cpu)
60+
self.assertEqual(20054016, rss)
61+
62+
def test_zero_precise_rss_is_valid(self):
63+
self.assertEqual(0, self.read_pair(19529728, 0)[1])
64+
65+
1866
if __name__ == '__main__':
1967
unittest.main()

0 commit comments

Comments
 (0)