haskell_generation_executor.py (6977B)
1 #!/usr/bin/env python3 2 """Fresh-process Haskell C-ABI executor for captured IO generation requests. 3 4 The supervisor owns transport, process guards and independent store audits. 5 This child authenticates the complete captured request before executing effects. 6 """ 7 import argparse 8 import hashlib 9 import json 10 from pathlib import Path 11 import resource 12 import subprocess 13 import sys 14 import time 15 16 ROOT = Path(__file__).resolve().parent.parent 17 BINDING = ROOT / 'ext/haskell/python/tricu.py' 18 sys.path.insert(0, str(BINDING.parent)) 19 from tricu import Library, TricuError 20 from generation_io import version, versions 21 22 23 def sha(path): 24 return hashlib.sha256(path.read_bytes()).hexdigest() 25 26 27 def validate_library(path, io_version): 28 if version(io_version) != 2: 29 raise ValueError('Haskell embedding requires IO version 2; select a retained runtime for v1') 30 # The supervisor never initializes an RTS. Validation and actual execution 31 # have separate, freshly exec'd processes, with no inherited GHC contexts. 32 result = subprocess.run([sys.executable, str(Path(__file__).resolve()), '--library', str(path), 33 '--validate-only'], capture_output=True, text=True, timeout=30) 34 if result.returncode: 35 raise ValueError('Haskell embedding library unavailable or incompatible; no fallback: ' + result.stderr[-2000:]) 36 37 38 def captured_request(output, library): 39 recorded = json.loads((output / 'inputs.json').read_text()) 40 request = json.loads((output / 'execution-request.json').read_text()) 41 execution = version(request.get('execution_io_version')) 42 if execution != 2 or execution != versions(recorded)[0]: 43 raise ValueError('execution request IO version mismatch') 44 if (recorded.get('execution_host') != 'haskell-ffi' 45 or recorded.get('runtime_sha256') != sha(library) 46 or recorded.get('executor_sha256') != sha(Path(__file__).resolve()) 47 or recorded.get('binding_sha256') != sha(BINDING)): 48 raise ValueError('execution runtime/launcher/binding identity mismatch') 49 if sha(output / 'source-map.v1') != recorded.get('map_sha256'): 50 raise ValueError('execution map identity mismatch') 51 expected = dict(execution_io_version=execution, 52 worker_export=recorded['worker_export'], 53 compiler_bundle_sha256=recorded['compiler_bundle_sha256'], 54 adapter_sha256=recorded['adapter_sha256'], 55 arguments=[recorded['source_root'], str(output / 'overlay'), str(output / 'cas'), 56 recorded['compiler_bundle_sha256'], (output / 'source-map.v1').read_text(), recorded['root']]) 57 if request != expected: 58 raise ValueError('execution request identity/arguments mismatch') 59 if any(not isinstance(text, str) or '\0' in text for text in request['arguments']): 60 raise ValueError('invalid compiler arguments') 61 compiler = (output / 'compiler.arboricx').read_bytes() 62 adapter = (output / 'io-adapter.arboricx').read_bytes() 63 if (hashlib.sha256(compiler).hexdigest() != request['compiler_bundle_sha256'] 64 or hashlib.sha256(adapter).hexdigest() != request['adapter_sha256']): 65 raise ValueError('captured executable identity mismatch') 66 return request, compiler, adapter 67 68 69 def diagnostic(ctx, roots, value): 70 # Observe only a bounded prefix of an ordinary compiler error. Terminal task 71 # diagnostics never enter this function and remain opaque. 72 characters, first = [], None 73 try: 74 while ctx.kind(value) == 2 and len(characters) < 240: 75 head = roots.child(value, 0) 76 number = ctx.to_number(head) 77 if first is None: 78 first = number 79 if number > 0x10ffff or 0xd800 <= number <= 0xdfff: 80 break 81 characters.append(chr(number)) 82 value = roots.child(value, 1) 83 if ctx.kind(value) == 0: 84 return ''.join(characters) 85 if len(characters) == 240: 86 return ''.join(characters) + '…' 87 except TricuError: 88 pass 89 return {'first_number': first} if first is not None else 'opaque compiler error' 90 91 92 def child(args): 93 resource.setrlimit(resource.RLIMIT_AS, (args.memory_mib * 1024**2,) * 2) 94 resource.setrlimit(resource.RLIMIT_STACK, (8 * 1024**2,) * 2) 95 resource.setrlimit(resource.RLIMIT_CPU, (args.seconds,) * 2) 96 resource.setrlimit(resource.RLIMIT_CORE, (0, 0)) 97 started = time.monotonic() 98 request, compiler, adapter = captured_request(args.output, args.library) 99 library = Library(args.library) 100 with library.context() as ctx, ctx.scope() as roots: 101 worker = roots.load_bundle(compiler, request['worker_export']) 102 wrapper = roots.load_bundle(adapter, 'main') 103 current = roots.apply_many(wrapper, worker, *(roots.string(text) for text in request['arguments'])) 104 prepared = time.monotonic() 105 program = roots.reduce(current) 106 forced = time.monotonic() 107 outcome, result, cleanup = roots.run_io(program) 108 ran = time.monotonic() 109 message, ok = None, False 110 if outcome != 0: 111 message = 'opaque terminal task failure/cancellation' 112 else: 113 if ctx.kind(cleanup) != 0 or ctx.kind(result) != 2: 114 raise ValueError('invalid compiler Result/cleanup shape') 115 ok = ctx.to_bool(roots.child(result, 0)) 116 payload = roots.child(result, 1) 117 if ctx.kind(payload) != 2 or ctx.kind(roots.child(payload, 1)) != 0: 118 raise ValueError('invalid compiler Result remainder') 119 if not ok: 120 message = diagnostic(ctx, roots, roots.child(payload, 0)) 121 record = dict(ok=ok, diagnostic=message, task_outcome=outcome, execution_io_version=2, 122 prepare_seconds=prepared-started, initial_reduce_seconds=forced-prepared, 123 io_seconds=ran-forced, total_seconds=time.monotonic()-started) 124 (args.output / 'execution.json').write_text(json.dumps(record, indent=2) + '\n') 125 if not ok: 126 raise RuntimeError(message) 127 print('ok: Haskell shared-library map-builder completed', flush=True) 128 print(json.dumps(record), flush=True) 129 130 131 if __name__ == '__main__': 132 parser = argparse.ArgumentParser(description=__doc__) 133 parser.add_argument('--library', type=Path, required=True) 134 parser.add_argument('--output', type=Path) 135 parser.add_argument('--validate-only', action='store_true') 136 parser.add_argument('--memory-mib', type=int, default=1024) 137 parser.add_argument('--seconds', type=int, default=30) 138 args = parser.parse_args() 139 if not 0 < args.memory_mib <= 65536 or not 0 < args.seconds <= 7200: 140 parser.error('invalid resource boundaries') 141 if args.validate_only: 142 Library(args.library) 143 elif args.output is None: 144 parser.error('--output is required for execution') 145 else: 146 child(args)