--- frozen-handoff/check_t01_resume.py +++ ACTION3_TIMEOUT_R01/source/check_t01_resume.py @@ -1,40 +1,190 @@ #!/usr/bin/env python3 """Bounded T01 confirmation harness; only disposable output directories are written. Uses real source preflight and runner execution, never journal-supplied executable code. +ACTION3 R01 integrates a test-only source-frame stall for --timeout-only. The fault +uses a real subprocess timeout; normal execution and final resume are uninjected. +No production/runtime certification or semantic-equivalence proof is implied. """ from __future__ import annotations -import argparse,copy,hashlib,json,os,shutil,subprocess,sys,time +import argparse,copy,hashlib,importlib.util,json,math,os,runpy,shutil,subprocess,sys,time,types +sys.dont_write_bytecode = True from pathlib import Path from datetime import datetime,timezone def sha(p):return hashlib.sha256(Path(p).read_bytes()).hexdigest() def write(p,d):Path(p).write_text(json.dumps(d,indent=2)+'\n') + +# ACTION3 R01: test-only fault injection, never a candidate-source patch. +# The real selected source frame is held before its first statement. The parent +# waits for that handshake before starting a real subprocess timeout. Startup +# speed and the runtime of unrelated commands therefore cannot select the fault. + +def positive_seconds(value): + value=float(value) + if not math.isfinite(value) or value <= 0: + raise argparse.ArgumentTypeError('timing must be finite and positive') + return value + +def nonnegative_seconds(value): + value=float(value) + if not math.isfinite(value) or value < 0: + raise argparse.ArgumentTypeError('delay must be finite and nonnegative') + return value + +def wait_for_source_frame(proc,ready,target,limit): + deadline=time.monotonic()+limit + while not ready.exists(): + code=proc.poll() + if code is not None: + raise RuntimeError('fault child exited before source-frame handshake: '+str(code)) + if time.monotonic() >= deadline: + raise RuntimeError('source-frame handshake deadline exceeded; NOT a verified timeout') + time.sleep(0.01) + event=json.loads(ready.read_text()) + if (event.get('kind')!='ACTION3_SOURCE_FRAME_STALL' or + event.get('pid')!=proc.pid or event.get('target')!=str(target) or + event.get('target_sha256')!=sha(target)): + raise RuntimeError('invalid source-frame handshake') + return event + +def run_with_source_stall(command,ready,timeout_seconds,ready_seconds,startup_delay,**kwargs): + """Run actual source through a test-only entry-frame gate; raise real timeout.""" + if not isinstance(command,list) or len(command)<3 or command[1]!='-B': + raise ValueError('unexpected source command shape') + target=Path(command[2]).resolve() + if ready.exists(): + raise ValueError('refusing existing fault evidence') + gated=[sys.executable,'-B',str(Path(__file__).resolve()),'--action3-gated-child', + str(ready),str(startup_delay),str(target),*command[3:]] + kwargs.pop('timeout',None) # Start fault budget ONLY after the handshake. + proc=subprocess.Popen(gated,**kwargs) + try: + wait_for_source_frame(proc,ready,target,ready_seconds) + # This is the real subprocess wait, not a fabricated exception or exit code. + code=proc.wait(timeout=timeout_seconds) + return subprocess.CompletedProcess(command,code) + finally: + if proc.poll() is None: + proc.kill() + proc.wait() + +def gated_child(argv): + ready=Path(argv[0]);delay=nonnegative_seconds(argv[1]);target=Path(argv[2]).resolve() + if ready.exists(): + raise ValueError('ready path already exists') + time.sleep(delay) # Optional explicit slow-start regression fixture. + sys.argv=[str(target),*argv[3:]];sys.path.insert(0,str(target.parent)) + def at_source(frame,event,arg): + if event=='call' and frame.f_code.co_filename==str(target): + body={'kind':'ACTION3_SOURCE_FRAME_STALL','pid':os.getpid(), + 'target':str(target),'target_sha256':sha(target), + 'phase':'before_first_statement','test_only':True} + temp=ready.with_suffix('.pending');write(temp,body);temp.replace(ready) + print('ACTION3 TEST-ONLY STALL: '+str(target),flush=True) + while True:time.sleep(3600) # Parent kills this process on its bounded timeout. + return at_source + sys.settrace(at_source) + try:runpy.run_path(str(target),run_name='__main__') + finally:sys.settrace(None) + raise RuntimeError('target frame was never intercepted') + +def subprocess_proxy(run): + return types.SimpleNamespace(**dict(vars(subprocess),run=run)) + +def fault_runner(argv): + script=Path(argv[0]).resolve();index=int(argv[1]);ready=Path(argv[2]).resolve() + budget=positive_seconds(argv[3]);startup=positive_seconds(argv[4]);delay=nonnegative_seconds(argv[5]) + sys.path.insert(0,str(script.parent)) + spec=importlib.util.spec_from_file_location('action3_frozen_runner',script) + module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module) + plan=module.command_plan() + if not 1<=index<=len(plan):raise ValueError('fault index outside actual plan') + target_name=plan[index-1][0] + if sum(name==target_name for name,_ in plan)!=1:raise ValueError('ambiguous fault target') + injected=[] + def controlled_run(command,**kwargs): + if len(command)>=3 and Path(command[2]).name==target_name: + if injected:raise RuntimeError('duplicate injection attempt') + injected.append(target_name) + return run_with_source_stall(command,ready,budget,startup,delay,**kwargs) + return subprocess.run(command,**kwargs) + # Change only this imported runner's subprocess binding for this fault run. + # Do not patch the global subprocess module, source files, plan, hashes or receipts. + module.subprocess=subprocess_proxy(controlled_run) + sys.argv=[str(script),*argv[6:]] + code=module.main() + if not injected:raise RuntimeError('requested fault was not reached') + return code + +def validate_timeout_checkpoint(seed,journal,failure,index): + """Derived state checks: no completed work may be credited to a failed attempt.""" + checks=[ + (failure.get('result')=='FAIL','missing failed reproduction receipt'), + (journal.get('commands')==seed.get('commands'),'verified checkpoint advanced or changed'), + (len(journal.get('attempts',[]))==len(seed.get('attempts',[]))+1,'unexpected attempt count'), + (journal.get('state')=='FAILED_OR_INCOMPLETE','journal not marked failed/incomplete'), + ] + for passed,reason in checks: + if not passed:raise AssertionError(reason) + attempt=journal['attempts'][-1] + if (attempt.get('index')!=index or attempt.get('exit_code')!=124 or + attempt.get('state')!='FAILED_OR_INCOMPLETE'): + raise AssertionError('requested command did not record a genuine incomplete timeout') + return attempt + def main(): - p=argparse.ArgumentParser(description=__doc__);p.add_argument('--candidate',type=Path,required=True);p.add_argument('--archive',type=Path,required=True);p.add_argument('--out',type=Path,required=True);p.add_argument('--actor',required=True);p.add_argument('--session',required=True);p.add_argument('--timeout-only',action='store_true');a=p.parse_args() + if sys.flags.optimize:raise RuntimeError('Run without -O / PYTHONOPTIMIZE; assertions must be enabled') + p=argparse.ArgumentParser(description=__doc__);p.add_argument('--candidate',type=Path,required=True);p.add_argument('--archive',type=Path,required=True);p.add_argument('--out',type=Path,required=True);p.add_argument('--actor',required=True);p.add_argument('--session',required=True);p.add_argument('--timeout-only',action='store_true');p.add_argument('--fault-at',type=int,default=3);p.add_argument('--fault-timeout',type=positive_seconds,default=0.2);p.add_argument('--fault-ready-timeout',type=positive_seconds,default=30.0);p.add_argument('--fault-startup-delay',type=nonnegative_seconds,default=0.0);p.add_argument('--runner-timeout',type=positive_seconds,default=600.0);a=p.parse_args() + if not 2<=a.fault_at<=12:p.error('--fault-at must be between 2 and 12') C=a.candidate.resolve();A=a.archive.resolve();O=a.out.resolve() if O.exists():raise ValueError('Output must be new; preserve previous evidence') if O==C or O.is_relative_to(C) or C.is_relative_to(O):raise ValueError('Source/output must be disjoint') O.mkdir(parents=True);results=[];run=O/'legitimate' initial={q.relative_to(C).as_posix():sha(q) for q in C.rglob('*') if q.is_file()} script=C/'runner/verify_review_packet.py' - def invoke(label,out,extra=(),actor=None,archive=None,expected=0): - log=O/(label+'.log');cmd=[sys.executable,'-B',str(script),'--actor',actor or a.actor,'--session',a.session,'--out',str(out),'--predecessor-archive',str(archive or A),*extra];start=time.time() - with log.open('wb') as f:r=subprocess.run(cmd,stdout=f,stderr=subprocess.STDOUT,timeout=600) - record={'id':label,'exit_code':r.returncode,'expected_exit_code':expected,'pass':r.returncode==expected,'log':log.name,'log_sha256':sha(log),'seconds':round(time.time()-start,3)} + def invoke(label,out,extra=(),actor=None,archive=None,expected=0,fault=False): + log=O/(label+'.log');cmd=[sys.executable,'-B',str(script),'--actor',actor or a.actor,'--session',a.session,'--out',str(out),'--predecessor-archive',str(archive or A),*extra] + if fault: + cmd=[sys.executable,'-B',str(Path(__file__).resolve()),'--action3-fault-runner',str(script),str(a.fault_at),str(O/'FAULT_READY.json'),str(a.fault_timeout),str(a.fault_ready_timeout),str(a.fault_startup_delay),*cmd[3:]] + start=time.monotonic() + with log.open('wb') as f:r=subprocess.run(cmd,stdout=f,stderr=subprocess.STDOUT,timeout=a.runner_timeout) + record={'id':label,'exit_code':r.returncode,'expected_exit_code':expected,'pass':r.returncode==expected,'log':log.name,'log_sha256':sha(log),'seconds':round(time.monotonic()-start,3)} results.append(record);write(O/'CHECKS.json',results);print(json.dumps(record),flush=True) if not record['pass']:raise AssertionError(label+' returned unexpected exit code') return log if a.timeout_only: - invoke('pause_after_2',run,['--stop-after','2'],expected=75) - invoke('forced_timeout',run,['--resume','--command-timeout','1'],expected=1) - failure=json.loads((run/'REVIEWER_REPRODUCTION_RECEIPT.json').read_text());assert failure['result']=='FAIL' - journal=json.loads((run/'COMMAND_PROGRESS.json').read_text());assert len(journal['commands'])==2 and journal['attempts'][-1]['exit_code']==124 - failure_hash=sha(run/'REVIEWER_REPRODUCTION_RECEIPT.json') + checkpoint=a.fault_at-1 + invoke('pause_after_'+str(checkpoint),run,['--stop-after',str(checkpoint)],expected=75) + seed=json.loads((run/'COMMAND_PROGRESS.json').read_text()) + assert len(seed['commands'])==checkpoint + invoke('forced_timeout',run,['--resume'],expected=1,fault=True) + failure_path=run/'REVIEWER_REPRODUCTION_RECEIPT.json' + failure=json.loads(failure_path.read_text()) + journal=json.loads((run/'COMMAND_PROGRESS.json').read_text()) + # A failed attempt must not advance the verified checkpoint or invent a PASS. + failed_attempt=validate_timeout_checkpoint(seed,journal,failure,a.fault_at) + ready=json.loads((O/'FAULT_READY.json').read_text()) + target=Path(ready['target']);assert target.name==seed['plan'][a.fault_at-1][0] + assert ready['target_sha256']==sha(C/'runner'/target.name) + failure_bytes=failure_path.read_bytes();failure_hash=sha(failure_path) invoke('resume_after_timeout',run,['--resume'],expected=0) - receipt=json.loads((run/'REVIEWER_REPRODUCTION_RECEIPT.json').read_text()) - assert (run/'receipt_history'/f'{failure_hash}.json').is_file() - journal=json.loads((run/'COMMAND_PROGRESS.json').read_text());assert any(x.get('exit_code')==124 for x in journal['attempts']) - results.append({'id':'failed_attempt_and_receipt_preserved','pass':True,'failed_receipt_sha256':failure_hash,'completed_commands_restored':receipt['resumptions'][-1]['completed_commands_restored']}) + receipt=json.loads(failure_path.read_text()) + assert (run/'receipt_history'/f'{failure_hash}.json').read_bytes()==failure_bytes + journal=json.loads((run/'COMMAND_PROGRESS.json').read_text()) + assert journal['commands'][:checkpoint]==seed['commands'] + assert failed_attempt in journal['attempts'] + assert len(journal['attempts'])==len(seed['plan'])+1 + assert [row['index'] for row in journal['commands']]==list(range(1,len(seed['plan'])+1)) + assert receipt['resumptions'][-1]['completed_commands_restored']==checkpoint + results.append({'id':'failed_attempt_and_receipt_preserved','pass':True, + 'failed_receipt_sha256':failure_hash,'fault_at_command':a.fault_at, + 'completed_commands_restored':checkpoint, + 'fault_mode':'test-only source-frame stall; real subprocess timeout', + 'fault_timeout_seconds':a.fault_timeout, + 'startup_handshake_timeout_seconds':a.fault_ready_timeout, + 'configured_startup_delay_seconds':a.fault_startup_delay, + 'fault_ready_sha256':sha(O/'FAULT_READY.json'), + 'resume_mode':'unmodified real runner; no injection'}) else: invoke('pause_after_9',run,['--stop-after','9'],expected=75) seed=json.loads((run/'COMMAND_PROGRESS.json').read_text());assert len(seed['commands'])==9 @@ -71,4 +221,7 @@ write(O/'CHECKS.json',results) report={'kind':'T01_FORCED_INTERRUPTION_CONFIRMATION' if a.timeout_only else 'T01_REAL_RUNNER_RESUME_CONFIRMATION','result':'PASS','actor':a.actor,'session':a.session,'at_utc':datetime.now(timezone.utc).isoformat(),'source_files':len(initial),'source_unchanged':True,'state_sha256':sha(C/'VPL_V5_CURRENT_STATE.json'),'reproduction_receipt_sha256':sha(run/'REVIEWER_REPRODUCTION_RECEIPT.json'),'commands':len(receipt['commands']),'recounted':receipt['recounted_tests'],'reports':exact,'checks':results,'boundary':'Executing actor evidence only. Not independent reviewer acceptance, authenticated identity, provider or production certification.'} write(O/'SUMMARY.json',report);print(json.dumps({'result':report['result'],'recounted':report['recounted'],'reports':len(exact)}),flush=True) -if __name__=='__main__':main() +if __name__=='__main__': + if len(sys.argv)>1 and sys.argv[1]=='--action3-gated-child':gated_child(sys.argv[2:]) + elif len(sys.argv)>1 and sys.argv[1]=='--action3-fault-runner':sys.exit(fault_runner(sys.argv[2:])) + else:main()