tricu

A lazy functional language based on Tree Calculus
Log | Files | Refs | README | LICENSE

handler_costs.py (18179B)


      1 #!/usr/bin/env python3
      2 """Matched-obligation handler/direct comparison and test-only check ablations.
      3 
      4 nix build .#
      5 python3 test/handler_costs.py result/bin/tricu --directory /tmp/handler-costs
      6 
      7 No production checks are removed. Private contract bodies are shared by exposing
      8 extra exports ONLY in a temporary compilation workspace. Every successful result
      9 and malformed-reply control has a Python oracle; samples include full rendering.
     10 """
     11 import argparse
     12 import hashlib
     13 import json
     14 import os
     15 from pathlib import Path
     16 import re
     17 import statistics
     18 
     19 from handler_library import tree, ok, err, diagnostic, http
     20 from http_headers import result, pair, number, tree_list
     21 from source_order import Runner, end, field
     22 from stable_bundle import byte_tree
     23 
     24 ROOT = Path(__file__).resolve().parent.parent
     25 PROFILES = {
     26     'matched': [1, 1, 1, 1, 1],
     27     'without-api-digest': [0, 1, 1, 1, 1],
     28     'without-body-bytes': [1, 0, 1, 1, 1],
     29     'without-context-list': [1, 1, 0, 1, 1],
     30     'without-event-shape': [1, 1, 1, 0, 1],
     31     'without-sink-reply': [1, 1, 1, 1, 0],
     32     'without-added-checks': [0, 0, 0, 0, 0],
     33 }
     34 
     35 
     36 def main():
     37     parser = argparse.ArgumentParser(description=__doc__)
     38     parser.add_argument('tricu', type=Path)
     39     parser.add_argument('--directory', type=Path, required=True)
     40     parser.add_argument('--repeats', type=int, default=5)
     41     parser.add_argument('--checks-only', action='store_true')
     42     args = parser.parse_args()
     43     if os.geteuid() == 0:
     44         parser.error("OS-denial controls require non-root")
     45     if not 3 <= args.repeats <= 10:
     46         parser.error('--repeats must be within 3..10')
     47     directory = args.directory.resolve()
     48     directory.mkdir(parents=True, exist_ok=False)
     49     inputs = directory / 'inputs'; inputs.mkdir()
     50     original_workspace = ROOT / 'test/handlers/tricu.workspace'
     51     locations = {}
     52     for line in original_workspace.read_text().splitlines()[1:]:
     53         alias, location = line.split('\t')
     54         locations[alias] = (original_workspace.parent / location).resolve()
     55     originals = dict(locations)
     56     for alias, exports in [('handler_artifacts', ['loadReplyC', 'byteC', 'bytesC']), ('handler_log', ['eventC', 'replyC'])]:
     57         original = locations[alias].read_text()
     58         extra = ''.join('!export '+name+'\n' for name in exports if '!export '+name+'\n' not in original)
     59         path = inputs / (alias+'.tri')
     60         lines = original.splitlines(keepends=True)
     61         boundary = max(i for i, line in enumerate(lines) if line.startswith('!export '))+1
     62         prefix, suffix = ''.join(lines[:boundary]), ''.join(lines[boundary:])
     63         path.write_text(prefix+extra+suffix)
     64         assert prefix+suffix == original
     65         locations[alias] = path
     66     locations['handler_fixture'] = ROOT / 'test/handlers/library.tri'
     67     (inputs / 'tricu.workspace').write_text('tricu.workspace.v1\n'+''.join(
     68         alias+'\t'+os.path.relpath(path, inputs)+'\n' for alias, path in locations.items()))
     69     source = ROOT / 'test/handlers/equivalent.tri'
     70     subject = inputs / 'equivalent.tri'; subject.write_bytes(source.read_bytes())
     71     runner = Runner(args.tricu, directory)
     72     bundle = directory / 'equivalent.arboricx'
     73     runner.run('compile', ['compile', '--file', subject, '--store', runner.store, '--output', bundle, '--compile-timeout', '600'], seconds=660)
     74     pin = runner.imported(bundle, 'import')
     75     passed = []
     76 
     77     def arguments(label, values):
     78         out = []
     79         for i, value in enumerate(values):
     80             path = directory / f'{label}-{i}.tree'; path.write_bytes(tree(value))
     81             out += ['--arg-tree-file', path]
     82         return out
     83 
     84     def invoke(label, export, values, permissions=()):
     85         output = runner.run(label, ['run', '--module', pin, '--name', export, '--store', runner.store,
     86             '--io', '--format', 'ternary', '--profile', *arguments(label, values), *permissions])
     87         text = next(re.match(r'^([012]+)', line)[1] for line in output.splitlines() if re.match(r'^[012]+', line))
     88         raw = bytes(map(int, text)); return raw[:end(raw)]
     89 
     90     def check(label, export, values, expected, permissions=()):
     91         actual = invoke(label, export, values, permissions)
     92         wanted = tree(expected)
     93         if actual != wanted:
     94             (directory / (label+'-actual.tree')).write_bytes(actual)
     95             (directory / (label+'-expected.tree')).write_bytes(wanted)
     96             raise AssertionError(label)
     97         passed.append(label)
     98 
     99     # Exact structural domains, not numeric coercion: all small trees plus
    100     # canonical values and malformed bit/tail mutations. Batch only to avoid
    101     # process startup dominating the exhaustive octet/hex checks.
    102     shapes = {1: [b'\0']}
    103     for nodes in range(2, 8):
    104         shapes[nodes] = [b'\1'+v for v in shapes[nodes-1]]
    105         for left in range(1, nodes-1):
    106             shapes[nodes] += [pair(a, b) for a in shapes[left] for b in shapes[nodes-1-left]]
    107     candidates = {number(n) for n in range(1025)}
    108     candidates.update(v for group in shapes.values() for v in group)
    109     for n in range(256):
    110         bits = [b'\1\0' if n >> i & 1 else b'\0' for i in range(n.bit_length())]
    111         candidates.add(tree_list(bits+[b'\0']))  # redundant high zero
    112         candidates.add(tree_list(bits, b'\1\0'))  # improper tail
    113         for i in range(len(bits)):
    114             for bad in [b'\1\1\0', pair(b'\0', b'\0')]:
    115                 changed = bits.copy(); changed[i] = bad
    116                 candidates.add(tree_list(changed))
    117     octets = {number(n) for n in range(256)}
    118     hexes = {number(n) for n in b'0123456789abcdef'}
    119     def pure_check(label, export, values, expected):
    120         actual = runner.invoke(label, pin, export, arguments(label, [values]))
    121         wanted = tree(expected)
    122         if actual != wanted:
    123             (directory / (label+'-expected.tree')).write_bytes(wanted)
    124             (directory / (label+'-actual.tree')).write_bytes(actual)
    125             raise AssertionError(label)
    126         passed.append(label)
    127     candidates = sorted(candidates)
    128     for start in range(0, len(candidates), 128):
    129         batch = candidates[start:start+128]
    130         pure_check(f'octets-{start}', 'validateOctets', batch,
    131             [result(True, v, number(7)) if v in octets else result(False, tree('expected octet'), number(7)) for v in batch])
    132         pure_check(f'hex-{start}', 'validateHex', batch,
    133             [b'\1\0' if v in hexes else b'\0' for v in batch])
    134     def length_oracle(value):
    135         count = 0
    136         while value[:1] == b'\2':
    137             count += 1
    138             value = field(value, 1)
    139         return number(count) if value == b'\0' else err('not a list')
    140     lengths = candidates + [tree([255]*n) for n in (0, 1, 7, 8, 63, 64, 255, 256, 1023, 1024, 4095, 4096)]
    141     for start in range(0, len(lengths), 128):
    142         batch = lengths[start:start+128]
    143         expected = [length_oracle(value) for value in batch]
    144         pure_check(f'lengths-{start}', 'validateLengths', batch, expected)
    145         pure_check(f'reference-lengths-{start}', 'validateReferenceLengths', batch, expected)
    146     pure_check('length-demand', 'lengthDemand', [], [1, err('not a list')])
    147     list_cases = [([], None), (list(range(256)), None), ([0, 256], 'expected octet'),
    148                   (b'\1\0', 'not a list'), (tree_list([number(0)], b'\1\0'), 'not a list'),
    149                   (tree_list([number(256)], b'\1\0'), 'expected octet')]
    150     pure_check('byte-lists', 'validateByteLists', [v for v, _ in list_cases],
    151         [result(True, tree(v), number(7)) if message is None else result(False, tree(message), number(7)) for v, message in list_cases])
    152     hashes = []
    153     for position in (0, 31, 63):
    154         for char in range(256):
    155             value = [97]*64; value[position] = char
    156             hashes.append((tree(value), char in b'0123456789abcdef'))
    157     for length in (0, 1, 63, 64, 65, 128):
    158         hashes.append((tree([97]*length), length == 64))
    159     hashes += [(tree_list([number(97)]*64, b'\1\0'), False),
    160                (tree_list([number(97)]*63+[pair(b'\0', b'\0')]), False)]
    161     for start in range(0, len(hashes), 128):
    162         batch = hashes[start:start+128]
    163         pure_check(f'hashes-{start}', 'validateHashes', [v for v, _ in batch],
    164             [result(True, v, number(7)) if valid else result(False, tree('expected 64 lowercase hexadecimal hash characters'), number(7)) for v, valid in batch])
    165     pure_check('hash-demand', 'hashDemand', [],
    166         [result(False, tree('expected 64 lowercase hexadecimal hash characters'), number(7))]*2)
    167     validator_cases = 2*len(candidates)+len(list_cases)+len(hashes)+2
    168     length_cases = 2*len(lengths)+2
    169 
    170     digest = 'a'*64
    171     fields = [['request', 7]]
    172     body = b'\0\xffA'
    173     good_read, good_log = ok(byte_tree(body)), ok(0)
    174     read_trace = ['read', digest]
    175     def log_trace(hash=digest, status=200, context=fields):
    176         return ['log', ['info', context, [hash, status]]]
    177     label = ['bundle-get']
    178     invalid_digest = 'expected 64 lowercase hexadecimal hash characters'
    179     cases = [
    180         ('success', fields, digest, good_read, good_log, ok(http(200, body)), [read_trace, log_trace()]),
    181         ('ordinary-read-error', fields, digest, err('private path'), good_log,
    182          ok(http(404, 'Artifact unavailable\n')), [read_trace, log_trace(status=404)]),
    183         ('invalid-digest', fields, '../secret', good_read, good_log,
    184          ok(http(400, invalid_digest)), [log_trace(hash='../secret', status=400)]),
    185         ('invalid-context', b'\1\0', digest, good_read, good_log,
    186          err(diagnostic('invalid-request', 'logging', 'context', 'context must be a proper list')), []),
    187         ('malformed-read', fields, digest, tree(0), good_log,
    188          err(diagnostic('invalid-response', 'artifacts', 'load', 'expected artifact-load Result with empty remainder', label)), [read_trace]),
    189         ('read-remainder', fields, digest, result(True, byte_tree(body), tree(1)), good_log,
    190          err(diagnostic('invalid-response', 'artifacts', 'load', 'expected artifact-load Result with empty remainder', label)), [read_trace]),
    191         ('byte-256', fields, digest, ok([256]), good_log,
    192          err(diagnostic('invalid-response', 'artifacts', 'load', 'expected artifact bytes', label)), [read_trace]),
    193         ('noncanonical-byte', fields, digest, ok([pair(b'\0', b'\0')]), good_log,
    194          err(diagnostic('invalid-response', 'artifacts', 'load', 'expected artifact bytes', label)), [read_trace]),
    195         ('improper-bytes', fields, digest, ok(pair(tree(65), b'\1\0')), good_log,
    196          err(diagnostic('invalid-response', 'artifacts', 'load', 'expected artifact bytes', label)), [read_trace]),
    197         ('ordinary-log-error', fields, digest, good_read, err('offline'),
    198          err(diagnostic('application-failure', 'logging', 'emit', 'offline', label)), [read_trace, log_trace()]),
    199         ('malformed-log', fields, digest, good_read, tree(0),
    200          err(diagnostic('invalid-response', 'logging', 'emit', 'expected log Result with empty remainder', label)), [read_trace, log_trace()]),
    201         ('log-nonunit', fields, digest, good_read, ok(1),
    202          err(diagnostic('invalid-response', 'logging', 'emit', 'expected log Result with empty remainder', label)), [read_trace, log_trace()]),
    203         ('log-remainder', fields, digest, good_read, result(True, tree(0), tree(1)),
    204          err(diagnostic('invalid-response', 'logging', 'emit', 'expected log Result with empty remainder', label)), [read_trace, log_trace()]),
    205     ]
    206     for name, context, hash, read_reply, log_reply, value, trace in cases:
    207         for export in ['directProbe', 'handlerProbe']:
    208             check(export+'-'+name, export, [context, hash, read_reply, log_reply], [value, trace])
    209 
    210     # Counterexamples establish that the switches really omit checks, while
    211     # invariant controls prove the envelope/path protections remain present.
    212     invalid_byte_response = tree([*b'HTTP/1.1 200 OK\r\nContent-Type: application/vnd.arboricx.bundle\r\nContent-Length: 1\r\nConnection: close\r\n\r\n', 256])
    213     check('omitted-byte-check', 'directProbeProfile',
    214           [PROFILES['without-body-bytes'], fields, digest, ok([256]), good_log],
    215           [ok(invalid_byte_response), [read_trace, log_trace()]])
    216     check('envelope-still-checked', 'directProbeProfile',
    217           [PROFILES['without-body-bytes'], fields, digest, result(True, byte_tree(body), tree(1)), good_log],
    218           [err(diagnostic('invalid-response', 'artifacts', 'load', 'expected artifact-load Result with empty remainder', label)), [read_trace]])
    219     check('omitted-reply-check', 'directProbeProfile',
    220           [PROFILES['without-sink-reply'], fields, digest, good_read, ok(1)],
    221           [ok(http(200, body)), [read_trace, log_trace()]])
    222     check('omitted-api-check-with-mock', 'directProbeProfile',
    223           [PROFILES['without-api-digest'], fields, 'bad', good_read, good_log],
    224           [ok(http(200, body)), [['read', 'bad'], log_trace(hash='bad')]])
    225 
    226     root = directory / 'artifacts'; root.mkdir()
    227     permissions = ()
    228     # Every profile retains the backend boundary, including the all-off control.
    229     check('raw-invalid', 'rawReader', [str(root), '../secret'], err(['invalid-digest', invalid_digest]))
    230     check('matched-invalid', 'ioEquivalent', [PROFILES['matched'], str(root), '../secret', [0]],
    231           ok([[http(400, invalid_digest)], [['info', [['request', 0]], ['../secret', 400]]]]))
    232     check('all-off-invalid', 'ioEquivalent', [PROFILES['without-added-checks'], str(root), '../secret', [0]],
    233           ok([[http(400, invalid_digest)], [['info', [['request', 0]], ['../secret', 400]]]]))
    234 
    235     variants = [('direct', 'ioDirect', None),
    236                 ('matched-direct', 'ioEquivalent', PROFILES['matched']),
    237                 ('library-sink', 'ioLibrarySink', None),
    238                 ('library-capture', 'ioLibraryCapture', None)]
    239     variants += [(name, 'ioEquivalent', profile) for name, profile in PROFILES.items() if name != 'matched']
    240     benchmarks = []
    241     if not args.checks_only:
    242         # Larger bodies expose the cost of the response-octet traversal. These
    243         # are still warm local files, not a network or concurrent-server test.
    244         for size, count in [(3, 20), (1024, 5)]:
    245             body = (bytes(range(256))*((size+255)//256))[:size]
    246             digest = hashlib.sha256(body).hexdigest()
    247             path = root / 'objects' / digest[:3] / (digest+'.arboricx')
    248             path.parent.mkdir(parents=True, exist_ok=True); path.write_bytes(body)
    249             contexts = list(range(count))
    250             expected = ok([[http(200, body)]*count,
    251                 [['info', [['request', context]], [digest, 200]] for context in contexts]])
    252             # Negative permission control for each primary path, same policy.
    253             for name, export, profile in variants[:4]:
    254                 values = ([profile] if profile is not None else []) + [str(root), digest, [0]]
    255                 denied = ok([[http(404, 'Artifact unavailable\n')],
    256                     [['info', [['request', 0]], [digest, 404]]]])
    257                 root.chmod(0)
    258                 try:
    259                     check(f'{name}-denied-{size}', export, values, denied)
    260                 finally:
    261                     root.chmod(0o700)
    262             rows = {name: [] for name, _, _ in variants}
    263             for repeat in range(args.repeats):
    264                 # Deterministic rotation across fresh processes; no --repeat.
    265                 offset = (repeat*3) % len(variants)
    266                 order = variants[offset:]+variants[:offset]
    267                 for name, export, profile in order:
    268                     label = f'bench-{size}-{name}-{repeat}'
    269                     values = ([profile] if profile is not None else []) + [str(root), digest, contexts]
    270                     check(label, export, values, expected, permissions)
    271                     rows[name].append(runner.records[-1])
    272             for name, export, profile in variants:
    273                 samples = rows[name]
    274                 benchmarks.append(dict(variant=name, export=export, profile=profile,
    275                     body_bytes=size, requests=count, repeats=args.repeats,
    276                     seconds=statistics.median(r['invoke_seconds'] for r in samples),
    277                     seconds_min=min(r['invoke_seconds'] for r in samples),
    278                     seconds_max=max(r['invoke_seconds'] for r in samples),
    279                     allocated_bytes=statistics.median(r['allocated_bytes'] for r in samples)))
    280     paths = {*originals.values(), ROOT / 'test/handlers/library.tri', source,
    281              Path(__file__).resolve(), ROOT / 'test/handler_library.py',
    282              ROOT / 'bootstrap/build-stable.arboricx', *inputs.iterdir()}
    283     report = dict(schema='tricu.handler-costs.v1', passed=passed, benchmarks=benchmarks,
    284         comparison='same-run obligation-matched direct versus library sink; simple direct omits handler-layer contracts',
    285         obligations=['API digest validation', 'backend digest validation', 'service Result envelope/remainder',
    286                      'canonical octet list', 'request context list', 'event shape before and after decoration',
    287                      'sink Result unit/remainder', 'contract success-envelope validation',
    288                      'identical host IO state logging', 'complete HTTP response/log rendering'],
    289         profiles=PROFILES, validator_cases=validator_cases, length_cases=length_cases, host=str(args.tricu.resolve()),
    290         bundle_sha256=hashlib.sha256(bundle.read_bytes()).hexdigest(),
    291         inputs={str(p): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted(paths)},
    292         limits=['all allocation figures are volume, not residency',
    293                 'direct/library share authenticated filesystem policy; trace accumulation is explicit source state, not native state',
    294                 'ablations are test-only and never disable backend digest/path or Result envelope checks',
    295                 'marginal ablation savings are not additive and are not pure contract execution timings',
    296                 'full response/log observation, warm reads, no networking or concurrency'])
    297     (directory / 'report.json').write_text(json.dumps(report, indent=2)+'\n')
    298     print(json.dumps(report, indent=2))
    299 
    300 
    301 if __name__ == '__main__':
    302     main()