tricu

An interpreted language for exploring Tree Calculus
Log | Files | Refs | README | LICENSE

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')