Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-11-09 10:47:24

0001 #!/usr/bin/env python3
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  # noqa: E402
0057 django.setup()
0058 
0059 from monitor_app.epicprod_inventory import trace_extract  # noqa: E402
0060 from monitor_app.epicprod_logging import log_epicprod_action  # noqa: E402
0061 from monitor_app.models import CrashSignature  # noqa: E402
0062 from monitor_app.reproductions import new_request_id, reconcile, update_host_entry  # noqa: E402
0063 from monitor_app.segfaults import (PAYLOAD_VERSION_FILE, _append_reproduction, is_es_unit,  # noqa: E402
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'           # under wenaus's home on npps0
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 # What the driver leaves that the record keeps.
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     # run.sh seeds a chunk with chunk + 1 (hepmc3 input); a slot names a unit
0133     # that does not open its block by the block and its start (es_slot.py).
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     # The job's resident simulation of external EVGEN (production_es.apply).
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 # DD4hep's handler names each signal the resident takes (state/resident.log).
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         # Detached with every stream away from the session, so the ssh
0254         # returns at once (a plain nohup ... & kept the session open).
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:                                    # noqa: BLE001
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                 # One slow look at the route is not the run's end.
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:                                    # noqa: BLE001
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         # The signal is named now (SEGFAULT_DIAGNOSIS.md, Event Service units,
0327         # Signature): set before the trace, whose frame key carries the exit.
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:                                    # noqa: BLE001
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())