File indexing completed on 2026-11-09 10:47:24
0001
0002 """segfault-es-reproduce.py — an Event Service unit's crash request replayed on npps0.
0003
0004 The reproduction of an Event Service unit signature (swf-epicprod
0005 docs/SEGFAULT_DIAGNOSIS.md, Event Service units). A crashed unit's events
0006 went back to their file and its job ended finished, so no payload canary
0007 replays it: the request the slot sent its resident simulation (input, first
0008 event, count, seed) is run again in the job's campaign image with the
0009 payload of the job's version, on npps0, the reference host of known
0010 conditions, through the npps0 route (the npps0 skill's npps0.sh). Never on
0011 the monitor host.
0012
0013 ``scripts/segfault-es-replay.sh`` is the driver on npps0: run.sh over the
0014 unit as a slot runs its first unit, then, when the resident dies, the
0015 request's arguments once more as a cold npsim under gdb. This doer:
0016
0017 1. picks the signature's most repeated crash request, or the one --unit
0018 names (its 1-based place in the signature's crash requests);
0019 2. records the attempt on the signature (mode host, queue npps0, role
0020 reference), so the runs page shows it while it runs;
0021 3. ships the payload of the job's version (the commit that set that version
0022 in payload/VERSION), the task's payload environment and the driver to
0023 npps0, starts the driver detached and polls for its result;
0024 4. fetches the result and logs into
0025 ``$SWF_TMP_DIR/segfault-packages/<key>/es-replay-<request id>/``;
0026 5. records the outcome on the entry and, for a crash, the signal on the
0027 signature and the trace read from gdb's backtrace (``record_trace``,
0028 merged by frame as a dig's), then reconciles the signature, which
0029 settles it and queues its study or marks it covered by a finding.
0030
0031 Django-bootstrap standalone script, by hand::
0032
0033 cd /data/wenauseic/github/swf-monitor/src
0034 ../../swf-testbed/.venv/bin/python ../scripts/segfault-es-reproduce.py --key esunit:task41497:simu
0035
0036 ``--dry-run`` prints the plan and writes nothing. Progress goes to stderr;
0037 the last stdout line is a JSON summary. Exit status 1 on an error.
0038 """
0039 import argparse
0040 import io
0041 import json
0042 import logging
0043 import os
0044 import re
0045 import shlex
0046 import subprocess
0047 import sys
0048 import tarfile
0049 import time
0050 from datetime import datetime, timezone as dt_timezone
0051
0052 THIS_DIR = os.path.dirname(os.path.abspath(__file__))
0053 sys.path.insert(0, os.path.join(THIS_DIR, '..', 'src'))
0054 os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'swf_monitor_project.settings')
0055
0056 import django
0057 django.setup()
0058
0059 from monitor_app.epicprod_inventory import trace_extract
0060 from monitor_app.epicprod_logging import log_epicprod_action
0061 from monitor_app.models import CrashSignature
0062 from monitor_app.reproductions import new_request_id, reconcile, update_host_entry
0063 from monitor_app.segfaults import (PAYLOAD_VERSION_FILE, _append_reproduction, is_es_unit,
0064 queue_diagnosis, record_trace, repro_environment)
0065
0066 NPPS0 = os.path.expanduser(os.environ.get('NPPS0_ROUTE', '~/.claude/skills/npps0/npps0.sh'))
0067 HOST = 'npps0'
0068 REMOTE_ROOT = 'work/segfault-es'
0069 SWF_TMP_DIR = os.environ.get('SWF_TMP_DIR', '/data/swf-tmp')
0070 DRIVER = os.path.join(THIS_DIR, 'segfault-es-replay.sh')
0071
0072 EVIDENCE = ['replay.out', 'out/result.json', 'out/payload.log', 'out/stages.log', 'out/gdb.log',
0073 'out/simulation.json', 'out/unit-template.json.npsim.json', 'state/resident.log',
0074 'gdb/args.log', 'gdb/cut.log', 'gdb/range.hepmc3.json']
0075 log = logging.getLogger('segfault-es-reproduce')
0076
0077
0078 def npps0(command, stdin=None, timeout=300):
0079 """Run a command on npps0; returns (rc, stdout bytes, stderr text)."""
0080 p = subprocess.run([NPPS0, command], input=stdin, capture_output=True, timeout=timeout)
0081 return p.returncode, p.stdout, p.stderr.decode(errors='replace')
0082
0083
0084 def payload_repo():
0085 """The swf-epicprod clone the payload versions are resolved in."""
0086 import swf_epicprod
0087 here = os.path.dirname(swf_epicprod.__file__)
0088 p = subprocess.run(['git', '-C', here, 'rev-parse', '--show-toplevel'],
0089 capture_output=True, text=True)
0090 if p.returncode:
0091 raise RuntimeError(f'swf_epicprod at {here} is not a git checkout: {p.stderr.strip()}')
0092 return p.stdout.strip()
0093
0094
0095 def version_commit(repo, version):
0096 """The commit that set ``version`` in payload/VERSION (local_repro's rule)."""
0097 p = subprocess.run(['git', '-C', repo, 'log', f'-S{version}', '--format=%H', '--',
0098 PAYLOAD_VERSION_FILE], capture_output=True, text=True)
0099 commits = p.stdout.split()
0100 if p.returncode or not commits:
0101 raise RuntimeError(f'no commit sets {version!r} in {PAYLOAD_VERSION_FILE}')
0102 return commits[-1]
0103
0104
0105 def plan(sig, unit_index):
0106 """What the replay runs: the request, the task, the image, the payload
0107 version, the run.sh arguments and the environment."""
0108 from pcs.models import PandaTasks
0109 units = (sig.data or {}).get('units') or []
0110 if not units:
0111 raise ValueError(f'{sig.key} records no crash request')
0112 if not 1 <= unit_index <= len(units):
0113 raise ValueError(f'--unit {unit_index}: {sig.key} has {len(units)} crash requests')
0114 unit = units[unit_index - 1]
0115 if None in (unit.get('first_event'), unit.get('events'), unit.get('seed')) or not unit.get('lfn'):
0116 raise ValueError(f'crash request {unit_index} lacks its input, first event, count or seed')
0117 tid = int((sig.tasks or [{}])[0].get('jeditaskid') or 0)
0118 attempt = PandaTasks.objects.filter(jedi_task_id=tid).select_related(
0119 'prod_task', 'prod_task__prod_config').first()
0120 if attempt is None or attempt.prod_task is None:
0121 raise ValueError(f'task {tid} has no PCS attempt record')
0122 md = attempt.metadata or {}
0123 version = str(md.get('payload_version') or '')
0124 if not version:
0125 raise ValueError(f'task {tid} records no payload version')
0126 files = ((md.get('manifest') or {}).get('files')) or []
0127 match = next((f for f in files
0128 if f"{os.path.basename(f['file'])}.{f['ext']}" == unit['lfn']), None)
0129 if match is None:
0130 raise ValueError(f"{unit['lfn']} is not in task {tid}'s manifest")
0131 first, count, seed = int(unit['first_event']), int(unit['events']), int(unit['seed'])
0132
0133
0134 chunk = seed - 1
0135 unit_events = int((md.get('event_service') or {}).get('unit_events') or 0)
0136 label = '' if first == chunk * (unit_events or count) else f'{chunk:04d}s{first + 1}'
0137 container = (sig.configuration or {}).get('container_image') or getattr(
0138 attempt.prod_task.prod_config, 'container_image', '')
0139 if not container:
0140 raise ValueError(f'task {tid} records no container image')
0141 env = repro_environment(attempt.prod_task)
0142
0143 env.update({'EPICPROD_SWITCH_RESIDENT_SIM': '1', 'EPICPROD_SWITCH_RESIDENT_EVGEN': '1'})
0144 return {'key': sig.key, 'jeditaskid': tid, 'task': attempt.prod_task.name,
0145 'unit_index': unit_index, 'unit': {k: unit.get(k) for k in
0146 ('lfn', 'first_event', 'events', 'seed', 'passes')},
0147 'crashed_jobs': sorted({c['pandaid'] for c in unit.get('crashes') or []}),
0148 'container': container, 'payload_version': version,
0149 'run_args': [f"EVGEN/{match['file']}", match['ext'], str(count), f'{chunk:04d}'],
0150 'skip': first, 'label': label, 'environment': env}
0151
0152
0153 def bundle(p, commit, repo, timeout_s):
0154 """The tarball shipped to npps0: payload/, environment-replay.sh,
0155 replay.env and the driver."""
0156 arch = subprocess.run(['git', '-C', repo, 'archive', '--format=tar', commit,
0157 'swf_epicprod/payload'], capture_output=True)
0158 if arch.returncode:
0159 raise RuntimeError(f'git archive {commit}: {arch.stderr.decode(errors="replace").strip()}')
0160 out = io.BytesIO()
0161 with tarfile.open(fileobj=out, mode='w:gz') as dst, \
0162 tarfile.open(fileobj=io.BytesIO(arch.stdout), mode='r:') as src:
0163 for member in src.getmembers():
0164 if not member.name.startswith('swf_epicprod/payload'):
0165 continue
0166 member.name = member.name[len('swf_epicprod/'):]
0167 dst.addfile(member, src.extractfile(member) if member.isfile() else None)
0168
0169 def add(name, text, mode=0o644):
0170 data = text.encode()
0171 info = tarfile.TarInfo(name)
0172 info.size, info.mode, info.mtime = len(data), mode, int(time.time())
0173 dst.addfile(info, io.BytesIO(data))
0174
0175 add('environment-replay.sh',
0176 ''.join(f'export {k}={shlex.quote(str(v))}\n' for k, v in p['environment'].items()))
0177 file, ext, events, chunk = p['run_args']
0178 add('replay.env', ''.join(f'export {k}={shlex.quote(str(v))}\n' for k, v in (
0179 ('REPLAY_FILE', file), ('REPLAY_EXT', ext), ('REPLAY_EVENTS', events),
0180 ('REPLAY_CHUNK', chunk), ('REPLAY_SKIP', p['skip']), ('REPLAY_LABEL', p['label']),
0181 ('REPLAY_SEED', p['unit']['seed']), ('REPLAY_TIMEOUT_S', timeout_s))))
0182 with open(DRIVER) as fh:
0183 add('segfault-es-replay.sh', fh.read(), 0o755)
0184 return out.getvalue()
0185
0186
0187 def frames_of(gdb_text):
0188 """The trace in gdb's log, through the shared reader."""
0189 if not gdb_text:
0190 return None
0191 result = trace_extract([gdb_text])
0192 return result if result.get('trace_status') == 'found' else None
0193
0194
0195
0196 CRASH_SIGNAL_RE = re.compile(r'Handle signal: (6|7|8|11)\b')
0197
0198
0199 def resident_reading(local, result):
0200 """(signal, outcome, reason) from the resident's own log as fetched: the
0201 first crash-class signal is the crash; a SIGTERM after it is the request
0202 timeout ending a resident that stalled after its signal; a SIGTERM alone
0203 is no crash (41494, 2026-10-29: SIGABRT, a stall, then SIGTERM at
0204 1,800 s, which a driver reading the last signal took for the crash)."""
0205 path = os.path.join(local, 'state', 'resident.log')
0206 text = open(path, errors='replace').read() if os.path.exists(path) else ''
0207 killed = 'Handle signal: 15' in text
0208 m = CRASH_SIGNAL_RE.search(text)
0209 if m:
0210 signal = int(m.group(1))
0211 return signal, 'crashed', (f'resident npsim died on signal {signal}'
0212 + (', stalled after it and was killed at the request timeout'
0213 if killed else ''))
0214 if result.get('outcome') == 'crashed' or killed:
0215 return None, 'inconclusive', ('the resident was killed at the request timeout '
0216 'with no crash signal')
0217 return result.get('signal'), result['outcome'], result.get('reason') or ''
0218
0219
0220 def iso(epoch):
0221 return datetime.fromtimestamp(epoch, tz=dt_timezone.utc).isoformat() if epoch else None
0222
0223
0224 def run(args):
0225 sig = CrashSignature.objects.filter(key=args.key).first()
0226 if sig is None:
0227 raise ValueError(f'no signature {args.key}')
0228 if not is_es_unit(sig):
0229 raise ValueError(f'{args.key} is not an Event Service unit signature')
0230 p = plan(sig, args.unit)
0231 repo = payload_repo()
0232 commit = version_commit(repo, p['payload_version'])
0233 p['payload_commit'] = commit
0234 if args.dry_run:
0235 return dict(p, dry_run=True, environment=sorted(p['environment']))
0236 rid = new_request_id()
0237 name = f"task{p['jeditaskid']}-{p['unit']['first_event']}-{rid}"
0238 remote = f'{REMOTE_ROOT}/{name}'
0239 local = os.path.join(SWF_TMP_DIR, 'segfault-packages', args.key, f'es-replay-{rid}')
0240 os.makedirs(local, exist_ok=True)
0241 pandaid = p['crashed_jobs'][0] if p['crashed_jobs'] else None
0242 _append_reproduction(args.key, {
0243 'request_id': rid, 'mode': 'host', 'queue': HOST, 'host': HOST, 'role': 'reference',
0244 'pandaid': pandaid, 'unit': p['unit'], 'container': p['container'],
0245 'payload_version': p['payload_version'], 'remote_dir': remote, 'evidence': local,
0246 'requested_at': datetime.now(dt_timezone.utc).isoformat(),
0247 'requested_by': args.requested_by, 'outcome': 'running'})
0248 try:
0249 rc, _out, err = npps0(f'mkdir -p ~/{remote} && tar -xz -C ~/{remote}',
0250 stdin=bundle(p, commit, repo, args.timeout))
0251 if rc:
0252 raise RuntimeError(f'shipping to npps0 failed (exit {rc}): {err.strip()[-300:]}')
0253
0254
0255 inner = (f'exec apptainer exec -B /cvmfs {shlex.quote(p["container"])} '
0256 f'bash ./segfault-es-replay.sh > replay.out 2>&1 < /dev/null')
0257 rc, _out, err = npps0(f'cd ~/{remote} && setsid -f bash -c {shlex.quote(inner)} '
0258 f'> /dev/null 2>&1 < /dev/null; echo started', timeout=120)
0259 if rc:
0260 raise RuntimeError(f'starting the driver failed (exit {rc}): {err.strip()[-300:]}')
0261 except Exception as e:
0262 update_host_entry(args.key, rid, {'outcome': 'inconclusive',
0263 'reason': f'replay failed: {str(e)[:250]}',
0264 'ended_at': datetime.now(dt_timezone.utc).isoformat()})
0265 raise
0266 log.info('%s: request %s replaying on npps0 in ~/%s', args.key, rid, remote)
0267 update_host_entry(args.key, rid, {'started_at': datetime.now(dt_timezone.utc).isoformat()})
0268 return collect(args, rid, remote, local, pandaid)
0269
0270
0271 def collect(args, rid, remote, local, pandaid):
0272 """Poll npps0 for the driver's result, fetch the evidence, record the
0273 outcome and the trace on the signature and reconcile it."""
0274 t0 = time.time()
0275 try:
0276 result = None
0277 deadline = time.time() + 2 * args.timeout + 600
0278 while time.time() < deadline:
0279 try:
0280 rc, out, err = npps0(f'cat ~/{remote}/out/result.json 2>/dev/null; '
0281 f'tail -1 ~/{remote}/out/stages.log 2>/dev/null >&2', timeout=120)
0282 except subprocess.TimeoutExpired:
0283
0284 log.warning('%s: a poll of npps0 timed out; trying again', args.key)
0285 time.sleep(args.poll)
0286 continue
0287 if out.strip():
0288 result = json.loads(out)
0289 break
0290 log.info('%s: running; %s', args.key, err.strip()[-200:] or 'no stage yet')
0291 time.sleep(args.poll)
0292 files = ' '.join(EVIDENCE)
0293 rc, tgz, err = npps0(f'cd ~/{remote} && tar -cz --ignore-failed-read {files} 2>/dev/null',
0294 timeout=300)
0295 if tgz:
0296 os.makedirs(local, exist_ok=True)
0297 with tarfile.open(fileobj=io.BytesIO(tgz), mode='r:gz') as t:
0298 t.extractall(local, filter='data')
0299 if result is None:
0300 raise RuntimeError(f'no result from npps0 within {2 * args.timeout + 600} s '
0301 f'(the driver may still run in ~/{remote}; --collect {rid} resumes)')
0302 except Exception as e:
0303 update_host_entry(args.key, rid, {'outcome': 'inconclusive',
0304 'reason': f'replay failed: {str(e)[:250]}',
0305 'ended_at': datetime.now(dt_timezone.utc).isoformat()})
0306 raise
0307 signal, outcome, reason = resident_reading(local, result)
0308 if result.get('gdb', {}).get('stopped_on'):
0309 reason += f"; the cold npsim under gdb stopped on {result['gdb']['stopped_on']}"
0310 fields = {'outcome': outcome, 'reason': reason,
0311 'exit_code': 128 + signal if signal else result.get('payload_exit'),
0312 'signal': signal, 'started_at': iso(result.get('started_at')),
0313 'ended_at': iso(result.get('ended_at')),
0314 'minutes': round((result['ended_at'] - result['started_at']) / 60.0, 1)
0315 if result.get('ended_at') and result.get('started_at') else None,
0316 'gdb': result.get('gdb'), 'simulation_reply': result.get('simulation_reply')}
0317 trace = None
0318 if outcome == 'crashed':
0319 gdb_log = os.path.join(local, 'out', 'gdb.log')
0320 trace = frames_of(open(gdb_log, errors='replace').read() if os.path.exists(gdb_log) else '')
0321 if trace is None:
0322 fields['reason'] += '; the cold npsim under gdb gave no backtrace'
0323 update_host_entry(args.key, rid, fields)
0324 merged = None
0325 if signal:
0326
0327
0328 sig = CrashSignature.objects.get(key=args.key)
0329 if sig.signal != signal:
0330 sig.signal, sig.exit_code = signal, 128 + signal
0331 sig.save(update_fields=['signal', 'exit_code', 'updated_at'])
0332 if trace is not None:
0333 sig = CrashSignature.objects.get(key=args.key)
0334 merged = record_trace(sig, trace, pandaid, '')
0335 changed = reconcile(args.key, queue_diagnosis=queue_diagnosis)
0336 return {'key': args.key, 'request_id': rid, 'outcome': outcome, 'signal': signal,
0337 'frame': (trace or {}).get('frame'), 'library': (trace or {}).get('library'),
0338 'merged_into': merged, 'reconcile': changed, 'evidence': local,
0339 'remote_dir': f'npps0:~/{remote}', 'seconds': int(time.time() - t0)}
0340
0341
0342 def resume(args):
0343 """--collect: take up a host attempt already running on npps0 from its
0344 entry (a doer that was interrupted leaves the driver running)."""
0345 sig = CrashSignature.objects.filter(key=args.key).first()
0346 entry = next((e for e in (sig.reproduction or []) if e.get('request_id') == args.collect
0347 and e.get('mode') == 'host'), None) if sig else None
0348 if entry is None:
0349 raise ValueError(f'{args.key} has no host attempt {args.collect}')
0350 if entry.get('outcome') != 'running' and not args.again:
0351 raise ValueError(f"attempt {args.collect} has ended ({entry.get('outcome')}); "
0352 f"--again records it once more from its evidence")
0353 return collect(args, args.collect, entry['remote_dir'], entry['evidence'], entry.get('pandaid'))
0354
0355
0356 def main():
0357 ap = argparse.ArgumentParser(description=__doc__.split('\n\n')[0])
0358 ap.add_argument('--key', required=True, help='an esunit: signature')
0359 ap.add_argument('--unit', type=int, default=1,
0360 help="the crash request's 1-based place among the signature's "
0361 '(default 1, the most repeated)')
0362 ap.add_argument('--timeout', type=int, default=3600,
0363 help='seconds for run.sh, and again for gdb, on npps0 (default 3600)')
0364 ap.add_argument('--poll', type=int, default=30, help='seconds between result checks')
0365 ap.add_argument('--requested-by', default='swf-testbed session')
0366 ap.add_argument('--dry-run', action='store_true', help='print the plan, write nothing')
0367 ap.add_argument('--collect', metavar='REQUEST_ID',
0368 help='take up the running host attempt REQUEST_ID of --key instead of starting one')
0369 ap.add_argument('--again', action='store_true',
0370 help='--collect: record an ended attempt once more from its evidence on npps0')
0371 args = ap.parse_args()
0372 logging.basicConfig(level=logging.INFO, stream=sys.stderr,
0373 format='%(asctime)s %(name)s %(levelname)s %(message)s')
0374 t0 = time.monotonic()
0375 try:
0376 summary = resume(args) if args.collect else run(args)
0377 except Exception as e:
0378 log.exception('replay of %s failed', args.key)
0379 if not args.dry_run:
0380 log_epicprod_action('segfault-es-reproduce', 'segfault_es_reproduction', outcome='error',
0381 subject_type='crash_signature', subject_key=args.key,
0382 username=args.requested_by, sublevel='normal', live_default=False,
0383 level=logging.ERROR, duration_ms=int((time.monotonic() - t0) * 1000),
0384 message=f'Event Service replay of {args.key} on npps0 failed: {str(e)[:200]}',
0385 reason=str(e)[:300])
0386 print(json.dumps({'key': args.key, 'error': str(e)[:300]}))
0387 return 1
0388 if not args.dry_run:
0389 log_epicprod_action(
0390 'segfault-es-reproduce', 'segfault_es_reproduction', outcome='ok',
0391 subject_type='crash_signature', subject_key=args.key, username=args.requested_by,
0392 sublevel='normal', live_default=False, duration_ms=int((time.monotonic() - t0) * 1000),
0393 message=(f"Event Service replay of {args.key} on npps0: {summary['outcome']}"
0394 + (f", signal {summary['signal']}" if summary.get('signal') else '')
0395 + (f", frame {summary['frame']}" if summary.get('frame') else '')),
0396 request_id=summary['request_id'], result=summary['outcome'],
0397 frame=summary.get('frame') or '')
0398 print(json.dumps(summary, default=str))
0399 return 0
0400
0401
0402 if __name__ == '__main__':
0403 sys.exit(main())