effect_server.py (11410B)
1 #!/usr/bin/env python3 2 """Opt-in artifact-server GET slice: captured services versus scoped interpreters. 3 4 nix build .# 5 python3 test/effect_server.py result/bin/tricu --directory /tmp/effect-server --benchmark 6 7 No listening socket or production source changes. Checks complete HTTP bytes, 8 logs, Result envelopes and effect traces against independent Python expectations. 9 Real filesystem runs use IO and independent byte/error expectations. 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 effect_interfaces import tree 20 from http_headers import result 21 from source_order import Runner, end 22 from stable_bundle import byte_tree 23 24 ROOT = Path(__file__).resolve().parent.parent 25 HASH = 'a' * 64 26 TARGET = '/_arboricx/bundle/hash/' + HASH 27 BODY = b'\x00\xffportable-tree\n' 28 29 30 def encode(value): 31 if isinstance(value, str): 32 return byte_tree(value.encode()) 33 if isinstance(value, (list, tuple)): 34 return tree([encode(x) for x in value]) 35 return tree(value) 36 37 38 def ok(value): 39 return result(True, encode(value)) 40 41 42 def err(value): 43 return result(False, encode(value)) 44 45 46 def response(status, body, content_type='text/plain; charset=utf-8'): 47 if isinstance(body, str): 48 body = body.encode() 49 phrase = {200: 'OK', 400: 'Bad Request', 404: 'Not Found'}[status] 50 head = (f'HTTP/1.1 {status} {phrase}\r\nContent-Type: {content_type}\r\n' 51 f'Content-Length: {len(body)}\r\nConnection: close\r\n\r\n').encode() 52 return byte_tree(head + body) 53 54 55 def success(body=BODY): 56 return response(200, body, 'application/vnd.arboricx.bundle') 57 58 59 def event(context, hash=HASH, status=200): 60 return [context, [hash, status]] 61 62 63 def script(context, backend=11, hash=HASH, reply=None, status=200): 64 return [([backend, 6, hash], ok(byte_tree(BODY)) if reply is None else reply), 65 ([30, 7, event(context, hash, status)], ok(1))] 66 67 68 def main(): 69 parser = argparse.ArgumentParser(description=__doc__) 70 parser.add_argument('tricu', type=Path) 71 parser.add_argument('--directory', type=Path, required=True) 72 parser.add_argument('--benchmark', action='store_true') 73 args = parser.parse_args() 74 if os.geteuid() == 0: 75 parser.error("OS permission-denial controls require a non-root user") 76 directory = args.directory.resolve() 77 directory.mkdir(parents=True, exist_ok=False) 78 runner = Runner(args.tricu, directory) 79 source = ROOT / 'test/effect_interfaces/server.tri' 80 bundle = directory / 'server.arboricx' 81 runner.run('compile', ['compile', '--file', source, '--store', runner.store, 82 '--output', bundle, '--compile-timeout', '270'], seconds=300) 83 pin = runner.imported(bundle, 'import') 84 passed = [] 85 86 def arguments(label, values): 87 result = [] 88 for index, value in enumerate(values): 89 path = directory / f'{label}-{index}.tree' 90 path.write_bytes(encode(value)) 91 result += ['--arg-tree-file', path] 92 return result 93 94 def check(label, export, values, rows, value, *, remaining=(), trace=None): 95 actual = runner.invoke(label, pin, export, arguments(label, [*values, rows])) 96 wanted = encode([value, remaining, [row[0] for row in rows] if trace is None else trace]) 97 if actual != wanted: 98 (directory / (label+'-expected.tree')).write_bytes(wanted) 99 raise AssertionError(label) 100 passed.append(label) 101 102 for variant in 'acd': 103 for mode, backend in [(0, 11), (1, 12), (2, 11)]: 104 context = f'request-{mode}' 105 check(f'{variant}-mode-{mode}', variant+'Request', [mode, context, 'GET', TARGET], 106 script(context, backend), ok(success())) 107 rows = script('shared', 11) + script('shared', 12, reply=ok(byte_tree(b'alternate'))) 108 check(variant+'-dual', variant+'Dual', ['shared', TARGET], rows, 109 ok([success(), success(b'alternate')])) 110 for label, reply, status, body in [ 111 ('missing', err('missing'), 404, 'Artifact unavailable\n'), 112 ('malformed', tree(0), 404, 'Invalid read result')]: 113 check(variant+'-'+label, variant+'Request', [0, 'r', 'GET', TARGET], 114 script('r', reply=reply, status=status), ok(response(status, body))) 115 # Production read protocol deliberately discards read Result remainder. 116 check(variant+'-read-remainder', variant+'Request', [0, 'r', 'GET', TARGET], 117 script('r', reply=result(True, byte_tree(BODY), byte_tree(b'unused'))), ok(success())) 118 for index, hash in enumerate(['../secret', 'A'*64, 'a'*63, 'a'*65]): 119 rows = [([30, 7, event('r', hash, 400)], ok(1))] 120 check(f'{variant}-invalid-{index}', variant+'Request', 121 [0, 'r', 'GET', '/_arboricx/bundle/hash/'+hash], rows, 122 ok(response(400, 'expected 64 lowercase hexadecimal hash characters'))) 123 for method, target in [('POST', TARGET), ('GET', '/other')]: 124 check(variant+'-not-found-'+method, variant+'Request', [0, 'r', method, target], [], 125 ok(response(404, 'Not found\n'))) 126 rows = script('r') 127 rows[-1] = (rows[-1][0], err('log unavailable')) 128 check(variant+'-log-failure', variant+'Request', [0, 'r', 'GET', TARGET], rows, 129 err('log unavailable')) 130 rows[-1] = (rows[-1][0], tree(42)) 131 check(variant+'-bad-log', variant+'Request', [0, 'r', 'GET', TARGET], rows, 132 err('Invalid completion-log response')) 133 # Repeated request contexts must not leak between sibling scopes. 134 check(variant+'-contexts', variant+'Requests', [['first', 'second'], TARGET], 135 script('first') + script('second'), ok([success(), success()])) 136 137 root, alternate = directory / 'primary', directory / 'alternate' 138 for path, contents in [(root, BODY), (alternate, b'alternate')]: 139 object_path = path / 'objects' / HASH[:3] / (HASH+'.arboricx') 140 object_path.parent.mkdir(parents=True) 141 object_path.write_bytes(contents) 142 143 def io_run(label, export, values): 144 output = runner.run(label, ['run', '--module', pin, '--name', export, 145 '--store', runner.store, '--io', '--format', 'ternary', '--profile', 146 *arguments(label, values)]) 147 text = next(re.match(r'^([012]+)', line)[1] for line in output.splitlines() 148 if re.match(r'^[012]+', line)) 149 raw = bytes(map(int, text)) 150 return raw[:end(raw)] 151 152 for variant in 'acd': 153 for mode, expected in [(0, success()), (1, success(b'alternate')), (2, success())]: 154 label = f'{variant}-filesystem-{mode}' 155 actual = io_run(label, variant+'Filesystem', 156 [str(root), str(alternate), mode, 'real', TARGET]) 157 assert actual == encode([ok(expected), [event('real')]]), label 158 passed.append(label) 159 actual = io_run(variant+'-native-log-failure', variant+'FilesystemLogFailure', 160 [str(root), str(alternate), 0, 'failed', TARGET]) 161 assert actual == encode([err('log unavailable'), [event('failed')]]) 162 passed.append(variant+'-native-log-failure-retains-attempt') 163 # Independent current expectations replace execution of the frozen v1 164 # route. Missing/OS-denied reads redact diagnostics; invalid hashes never 165 # reach the file adapter. Actual v2 registry integrity is tested separately. 166 for label, target, status, body in [ 167 ('success', TARGET, 200, BODY), 168 ('invalid', '/_arboricx/bundle/hash/../secret', 400, 169 'expected 64 lowercase hexadecimal hash characters'), 170 ('missing', '/_arboricx/bundle/hash/'+'b'*64, 404, 'Artifact unavailable\n'), 171 ('denied', TARGET, 404, 'Artifact unavailable\n')]: 172 if label == 'denied': 173 root.chmod(0) 174 try: 175 actual = io_run(f'{variant}-filesystem-check-{label}', variant+'Filesystem', 176 [str(root), str(alternate), 0, 'real', target]) 177 finally: 178 root.chmod(0o700) 179 hash = target.removeprefix('/_arboricx/bundle/hash/') 180 expected = success() if status == 200 else response(status, body) 181 assert actual == encode([ok(expected), [event('real', hash, status)]]), label 182 passed.append(variant+'-filesystem-'+label) 183 assert not (directory / 'secret').exists() 184 assert (root / 'objects' / HASH[:3] / (HASH+'.arboricx')).read_bytes() == BODY 185 186 benchmarks = [] 187 if args.benchmark: 188 for backend in ['scripted', 'filesystem']: 189 for count in [1, 20]: 190 contexts = list(range(count)) 191 rows = [row for context in contexts for row in script(context)] 192 samples = {variant: [] for variant in 'acd'} 193 for repeat, order in enumerate(['acd', 'cda', 'dac']): 194 for variant in order: 195 label = f'bench-{backend}-{variant}-{count}-{repeat}' 196 if backend == 'scripted': 197 check(label, variant+'Requests', [contexts, TARGET], rows, ok([success()]*count)) 198 else: 199 actual = io_run(label, variant+'RequestsIO', 200 [str(root), str(alternate), contexts, TARGET]) 201 assert actual == encode([ok([success()]*count), [event(context) for context in contexts]]), label 202 passed.append(label) 203 samples[variant].append(runner.records[-1]) 204 for variant, records in samples.items(): 205 benchmarks.append(dict(backend=backend, count=count, variant=variant, 206 seconds=statistics.median(r['invoke_seconds'] for r in records), 207 allocated_bytes=statistics.median(r['allocated_bytes'] for r in records))) 208 sources = [source, ROOT / 'test/effect_interfaces/workflow.tri', Path(__file__).resolve(), 209 ROOT / 'test/effect_interfaces/tricu.workspace', ROOT / 'lib/arboricx/server.tri', 210 ROOT / 'lib/http.tri', ROOT / 'lib/base.tri', ROOT / 'lib/io.tri', ROOT / 'lib/io/storage.tri', 211 ROOT / 'bootstrap/build-stable.arboricx'] 212 report = dict(passed=passed, benchmarks=benchmarks, host=str(args.tricu.resolve()), 213 bundle_sha256=hashlib.sha256(bundle.read_bytes()).hexdigest(), 214 inputs={str(path.relative_to(ROOT)): hashlib.sha256(path.read_bytes()).hexdigest() 215 for path in sources}, 216 limits=['GET request slice, not sockets or full server throughput', 217 'completion logging added by experiment, not shipped behavior', 218 'filesystem benchmarks use warm budgeted local reads and explicit source-state log capture', 219 'all request responses fully rendered; scripted runs also render full traces, filesystem runs logs', 220 'no route indexing; C/D use the same linear lookup']) 221 (directory / 'report.json').write_text(json.dumps(report, indent=2)+'\n') 222 print(json.dumps(report, indent=2)) 223 224 225 if __name__ == '__main__': 226 main()