default_frontend.py (13555B)
1 #!/usr/bin/env python3 2 """Opt-in edited-host portable frontend controls; no generations or fixtures.""" 3 import os 4 import hashlib 5 import importlib.util 6 import json 7 from pathlib import Path 8 import subprocess 9 import sys 10 import tempfile 11 import time 12 import signal 13 from process_rss import ChildRSS 14 from native_indexed_store import indexed_bytes 15 from http_headers import number, tree_list 16 17 host = str(Path(sys.argv[1]).resolve()) 18 repo = Path(__file__).resolve().parents[1] 19 bundle = repo / 'bootstrap/build-stable.arboricx' 20 with tempfile.TemporaryDirectory(prefix='tricu-frontend-') as directory: 21 root = Path(directory) 22 store = root / 'store' 23 24 def run(*args, input=None, env=None, ok=True): 25 command = [host, *map(str, args), '+RTS', '-N1', '-M8192m', '-RTS'] 26 with tempfile.TemporaryFile() as stdin, tempfile.TemporaryFile() as stdout, tempfile.TemporaryFile() as stderr: 27 stdin.write((input or '').encode()); stdin.seek(0) 28 proc = subprocess.Popen(command, cwd=root, stdin=stdin, stdout=stdout, 29 stderr=stderr, env=env, start_new_session=True) 30 deadline = time.monotonic() + 180 31 monitor = ChildRSS(proc) 32 try: 33 while proc.poll() is None: 34 rss = monitor.sample() 35 assert rss is None or rss <= 8192 * 1024, 'RSS guard exceeded' 36 assert time.monotonic() < deadline, '180-second guard exceeded' 37 time.sleep(.05) 38 finally: 39 if proc.poll() is None: 40 os.killpg(proc.pid, signal.SIGKILL) 41 proc.wait() 42 stdout.seek(0); stderr.seek(0) 43 output = stdout.read().decode() + stderr.read().decode() 44 assert (proc.returncode == 0) == ok, (args, output) 45 return output 46 47 source = root / 'example.tri' 48 source.write_text('''!export main 49 !export extra 50 copy xs = (let rec 51 walk [] = [] 52 walk [h | rest] = t h (walk rest) 53 walk _ = [] 54 in walk xs) 55 main = case (copy [42 7]) of 56 [h | _] = h 57 _ = 0 58 extra = 9 59 ''') 60 assert run('eval', source, '--store', store, '--format', 'number') == '42' 61 def receipt_for(name): 62 checkpoint = (store / 'cache/source-build' / name).read_text().split('\t') 63 payload = (store / 'objects' / checkpoint[3][:3] / checkpoint[3]).read_bytes() 64 assert hashlib.sha256(b'arboricx.build-receipt.v1\0' + payload).hexdigest() == checkpoint[3] 65 return checkpoint[3], payload 66 67 default_receipt, receipt = receipt_for('example') 68 source_hash = hashlib.sha256(b'arboricx.source.v1\0' + source.read_bytes()).hexdigest().encode() 69 assert b'source\t' + source_hash + b'\n' in receipt 70 assert b'compiler\t' + hashlib.sha256(bundle.read_bytes()).hexdigest().encode() + b'\n' in receipt 71 assert run('eval', '--store', store, '--format', 'number', 72 input='!export main\nmain = 17\n') == '17' 73 unicode_source = root / 'unicode.tri' 74 text = 'é€😀e\u0301' 75 unicode_source.write_bytes(('!export main\n-- Unicode comment: ' + text + '\nmain = "' + text + '"\n').encode('utf-8')) 76 expected_tree = ''.join(map(str, tree_list([number(ord(c)) for c in text]))) 77 assert run('eval', unicode_source, '--store', store, '--format', 'ternary') == expected_tree 78 _, unicode_receipt = receipt_for('unicode') 79 assert ('source\t' + hashlib.sha256(b'arboricx.source.v1\0' + unicode_source.read_bytes()).hexdigest() + '\n').encode() in unicode_receipt 80 assert run('eval', '--store', store, '--format', 'ternary', 81 input='!export main\nmain = "' + text + '"\n') == expected_tree 82 for raw, diagnostic in [(b'!export main\nmain = "\xc0\xaf"\n', 'invalid UTF-8 source'), 83 (b'\xef\xbb\xbf!export main\nmain = t\n', 'UTF-8 source BOM is not permitted')]: 84 bad_unicode = root / 'badunicode.tri' 85 bad_unicode.write_bytes(raw) 86 diagnostic_output = run('eval', bad_unicode, '--store', store, ok=False) 87 assert diagnostic in diagnostic_output, (diagnostic, diagnostic_output) 88 assert bad_unicode.read_bytes() == raw 89 assert not (store / 'cache/source-build/badunicode').exists() 90 91 output = root / 'example.arboricx' 92 run('arboricx', 'compile', '--file', source, '--store', store, '--output', output) 93 imported = run('arboricx', 'import', '--file', output, '--store', store) 94 manifest = imported.strip().split()[-1] 95 assert 'Imported 2 root(s)' in imported 96 assert run('arboricx', 'run', '--module', manifest, '--name', 'extra', 97 '--store', store, '--format', 'number') == '9' 98 tools = root / 'tools' 99 tool_import = run('arboricx', 'import', '--file', bundle, '--store', tools) 100 tool_manifest = tool_import.strip().split()[-1] 101 tool_object = next(line.split()[-1] for line in tool_import.splitlines() if 'sourceBuildGraph ->' in line) 102 broken = dict(os.environ, tricu_datadir=str(root / 'missing')) 103 assert 'Packaged stable compiler unavailable' in run('eval', source, '--store', store, env=broken, ok=False) 104 assert 'Packaged stable compiler unavailable' in run('--store', store, input='!exit\n', env=broken, ok=False) 105 assert run('eval', source, '--store', store, '--toolchain', tool_manifest, 106 '--tool-store', tools, '--format', 'number', env=broken) == '42' 107 override_receipt, receipt_bytes = receipt_for('example') 108 assert override_receipt != default_receipt, 'selection cache identities must be distinct' 109 assert ('compiler\t' + tool_object + '\n').encode() in receipt_bytes 110 assert ('compiler\t' + tool_manifest + '\n').encode() not in receipt_bytes 111 assert tool_object != hashlib.sha256(bundle.read_bytes()).hexdigest() 112 corrupt = root / 'corrupt' / 'bootstrap' 113 corrupt.mkdir(parents=True) 114 (corrupt / bundle.name).write_bytes(b'not a compiler') 115 assert 'SHA256 mismatch' in run('eval', source, '--store', store, 116 env=dict(os.environ, tricu_datadir=str(corrupt.parent)), ok=False) 117 assert '--tool-store requires --toolchain' in run('eval', source, '--store', store, 118 '--tool-store', tools, ok=False) 119 bad = root / 'bad.tri' 120 bad.write_text('!export main\nmain = unknown\n') 121 assert 'Portable compilation failed' in run('eval', bad, '--store', store, ok=False) 122 lazy = root / 'lazy.tri' 123 lazy.write_text('!export main\nloop x = loop x\nmain = case (loop t) of\n _ = 29\n') 124 assert run('eval', lazy, '--store', store, '--format', 'number') == '29' 125 run('tree', 'write', source, '--name', 'extra', '--output', root / 'extra.tree', '--store', store) 126 assert (root / 'extra.tree').is_file() 127 exact = root / 'exact.tri' 128 exact.write_text('!export chosen\n!export other\nchosen = t (t t) (t t t)\nother = t\n') 129 payload = root / 'exact.tree' 130 run('tree', 'write', exact, '--name', 'chosen', '--output', payload, '--store', store) 131 assert payload.read_bytes() == bytes([2, 1, 0, 2, 0, 0]), 'constructor shape or selected definition changed' 132 run('tree', 'write', exact, '--name', 'absent', '--output', payload, '--store', store, ok=False) 133 assert payload.read_bytes() == bytes([2, 1, 0, 2, 0, 0]), 'failed selection replaced output' 134 run('arboricx', 'compile', '--file', exact, '--output', root / 'exact.arboricx', '--store', store) 135 exact_pin = run('arboricx', 'import', '--file', root / 'exact.arboricx', '--store', store).strip().split()[-1] 136 assert run('arboricx', 'run', '--module', exact_pin, '--name', 'chosen', '--format', 'ternary', '--store', store) == '210200' 137 # Exercise the actual migrated vector-authoring helpers against unchanged 138 # published independent expectations; never run preparation/publication. 139 spec = importlib.util.spec_from_file_location('demo_prepare', repo / 'ext/demo-site/prepare.py') 140 prepare = importlib.util.module_from_spec(spec) 141 spec.loader.exec_module(prepare) 142 vectors = json.loads((repo / 'ext/demo-site/demos.json').read_text()) 143 for vector in vectors['codecVectors'] + vectors['applyVectors']: 144 if 'kind' in vector: 145 text = prepare.codec_vector_source(vector['kind'], vector['value']) 146 expected = vector['tree'] 147 else: 148 text = prepare.apply_vector_source(vector['program'], vector['argument']) 149 expected = vector['result'] 150 exact.write_text(text) 151 run('tree', 'write', exact, '--name', 'value', '--output', payload, '--store', store) 152 assert payload.read_bytes() == bytes(map(int, expected)), vector 153 for script, arguments in [('stable_bundle.py', ['--compiler-bundle', str(bundle), '--directory', str(root / 'retired')]), 154 ('stable_costs.py', [str(root / 'retired')])]: 155 reply = subprocess.run([sys.executable, str(repo / 'test' / script), host, *arguments], 156 capture_output=True, text=True, timeout=10) 157 assert reply.returncode and 'Retired implicit recovery preparation' in reply.stderr 158 assert not (root / 'retired').exists() 159 formatter_input = root / 'formatter.tri' 160 for raw, diagnostic in [(b'\xef\xbb\xbfmain = t\n', 'initial UTF-8 BOM'), 161 (b'\xc0\xaf', 'Invalid UTF-8')]: 162 formatter_input.write_bytes(raw) 163 assert diagnostic in run('format', formatter_input, '--formatter', '0' * 64, 164 '--store', store, '--write', ok=False) 165 assert formatter_input.read_bytes() == raw 166 167 # The artifact-only fast gate exercises the stored loader. These separate 168 # opt-in controls retain the native source-import equivalents. 169 def put_object(domain, data): 170 pin = hashlib.sha256(domain.encode() + b'\0' + data).hexdigest() 171 path = store / 'objects' / pin[:3] / pin 172 path.parent.mkdir(parents=True, exist_ok=True) 173 path.write_bytes(data) 174 return pin, path 175 176 tree_bytes = indexed_bytes(b'\0') 177 tree_pin, tree_path = put_object('arboricx.indexed-tree.v1', tree_bytes) 178 row = f'export\tvalue\tarboricx.indexed-tree.v1\t{tree_pin}\tarboricx.abi.tree.v1\n'.encode() 179 manifest_bytes = b'arboricx.module-manifest.v1\n' + row 180 provider_pin, provider_path = put_object('arboricx.module-manifest.v1', manifest_bytes) 181 importing = root / 'importing.tri' 182 183 def import_source(pin, ok=True): 184 importing.write_text(f'!import {pin} as D\n!export main\nmain = D.value\n') 185 return run('eval', importing, '--store', store, '--format', 'fsl', ok=ok) 186 187 assert import_source(provider_pin) == 'Leaf' 188 provider_path.write_bytes(manifest_bytes + b'corrupt') 189 assert 'CAS digest mismatch' in import_source(provider_pin, ok=False) 190 provider_path.write_bytes(manifest_bytes) 191 tree_path.write_bytes(b'\1\0') 192 assert 'CAS digest mismatch' in import_source(provider_pin, ok=False) 193 tree_path.write_bytes(tree_bytes) 194 outside = root / 'outside.tree' 195 outside.write_bytes(b'\0') 196 for data, message in [(manifest_bytes.replace(b'arboricx.indexed-tree.v1', b'arboricx.tree-term.v1'), 'unsupported export kind'), 197 (manifest_bytes + row, 'duplicate module export'), 198 (manifest_bytes.replace(b'arboricx.abi.tree.v1', b'wrong.abi'), 'unsupported export ABI'), 199 (manifest_bytes.replace(tree_pin.encode(), str(outside).encode()), 'invalid export name or digest')]: 200 invalid, _ = put_object('arboricx.module-manifest.v1', data) 201 assert message in import_source(invalid, ok=False) 202 assert outside.read_bytes() == b'\0' 203 204 (root / 'dep.tri').write_text('!export value\nvalue = 23\n') 205 transcript = run('--store', root / 'interactive', input='''!format number 206 x = 7 207 x 208 f ignored = x 209 f t 210 f "a=b" 211 x = 8 212 x 213 f t 214 x = unknown 215 x 216 f t 217 !import dep as D 218 D.value 219 !load dep.tri 220 value 221 !def first 222 first xs = case xs of 223 [h | _] = h 224 _ = 0 225 !end 226 first [31] 227 !expr 228 (let rec 229 walk [] = 19 230 walk [h | rest] = walk rest 231 walk _ = 0 232 in walk [1 2]) 233 !end 234 !import missing * 235 x 236 !load bad.tri 237 x 238 !reset 239 x 240 !exit 241 ''') 242 for expected in ['tricu < 7\n', 'tricu < 8\n', 'tricu < 23\n', 'tricu < 31\n', '19\n']: 243 assert expected in transcript, (expected, transcript) 244 assert transcript.count('tricu < 8\n') == 6, transcript 245 assert transcript.count('Error:') == 4, transcript 246 assert not list(root.glob('tricu_repl*.tri')), 'session temporary source leaked' 247 block_transcript = run('--store', root / 'blockstore', input='''!format number 248 x = 3 249 !def x 250 y = 7 251 !end 252 f ignored = x 253 x 254 f t 255 !env 256 !def x 257 x = 8 258 extra = 9 259 !end 260 x 261 f t 262 !env 263 !def x 264 -- Leading comments belong to the submitted source. 265 x = 11 266 !end 267 x 268 f t 269 !env 270 !exit 271 ''') 272 assert block_transcript.count('Definition block must define exactly its named equation group') == 2, block_transcript 273 assert block_transcript.count('Error:') == 2, block_transcript 274 assert block_transcript.count('tricu < 3\n') == 4, block_transcript 275 assert block_transcript.count('tricu < 11\n') == 2, block_transcript 276 assert block_transcript.count('tricu < f\nx\n') == 3, block_transcript 277 assert 'tricu < y\n' not in block_transcript and '\nextra\n' not in block_transcript 278 overridden = run('--store', store, 'repl', '--toolchain', tool_manifest, '--tool-store', tools, 279 input='!format number\n42\n!exit\n', env=broken) 280 assert '42\n' in overridden 281 print('default portable frontend: CLI, packaging, overrides, errors and transactional REPL passed')