oxedyne/daimond/hand/install/mock_host.py
7.9 KiB, 1 run
created by r2519314175:907, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | #!/usr/bin/env python3 |
| 2 | """A stand-in for the machine hand, so the relay can be driven without Rust. |
| 3 | |
| 4 | It speaks the real framing -- a 4-byte native-endian length prefix followed by |
| 5 | UTF-8 JSON, which is Chrome's format and not negotiable -- and the real |
| 6 | messages, from ``hand/src/wire.rs``. It runs nothing. Every ``exec`` produces |
| 7 | invented output on a schedule, which is exactly what is wanted: the relay's job |
| 8 | is order, attribution and the shape of failure, and none of that needs a real |
| 9 | process to exercise. |
| 10 | |
| 11 | It is also the only way to reach the failures that matter. A gap in the |
| 12 | sequence, a message over Chrome's 1 MB limit, and a host that dies mid-command |
| 13 | are all things a correct hand will never do, so a correct hand cannot be used to |
| 14 | test what happens when they occur. |
| 15 | |
| 16 | Behaviour is read from ``mock_cfg.json`` beside this file, because Chrome gives |
| 17 | a native messaging host no arguments of its own -- it passes the calling |
| 18 | extension's origin as ``argv[1]`` and nothing else -- and the browser's |
| 19 | environment is not the test runner's to set. |
| 20 | |
| 21 | {"chunks": 3, "gap": false, "huge": false, "crash": false, "delay_ms": 0} |
| 22 | |
| 23 | chunks how many chunks per stream |
| 24 | gap skip one sequence number, so the relay must report a hole |
| 25 | huge send one message over 1 MB, which makes Chrome drop the |
| 26 | connection without telling either end why |
| 27 | crash exit after "started", as a host that segfaults would |
| 28 | delay_ms pause between chunks, for testing a long, quiet run |
| 29 | caps what the "hello" claims this hand can enforce. The page reads |
| 30 | the granted folder out of this list as a "root:<path>" entry, |
| 31 | because ``wire.rs`` has no field for it |
| 32 | exit the status the command ends with, for proving a failure is |
| 33 | reported as a failure |
| 34 | quiet_ms silence between "started" and the first chunk, which is what a |
| 35 | build that says nothing for a minute looks like |
| 36 | noise_ms send an unrecognised message this often and NOTHING else -- no |
| 37 | "started", no output, no end. A page that treats any message as |
| 38 | proof of life waits for ever on it |
| 39 | mute never answer "hello". The port opens, the host is there, and the |
| 40 | greeting the page is waiting for never comes -- which is what the |
| 41 | page's handshake deadline exists for, and what it SAYS when that |
| 42 | deadline passes is the thing under test |
| 43 | |
| 44 | Everything it receives and sends is appended to ``mock_host.log`` beside it, so |
| 45 | a test can prove the relay sent ``bye`` when the page went away rather than |
| 46 | leaving an orphan. |
| 47 | """ |
| 48 | |
| 49 | import json |
| 50 | import os |
| 51 | import struct |
| 52 | import sys |
| 53 | import time |
| 54 | |
| 55 | HERE = os.path.dirname(os.path.abspath(__file__)) |
| 56 | CFG = os.path.join(HERE, 'mock_cfg.json') |
| 57 | LOG = os.path.join(HERE, 'mock_host.log') |
| 58 | |
| 59 | PROTO = 1 |
| 60 | |
| 61 | |
| 62 | def note(what, msg): |
| 63 | """Records one line of the conversation, best effort.""" |
| 64 | try: |
| 65 | with open(LOG, 'a', encoding='utf-8') as fh: |
| 66 | fh.write('%.3f %s %s\n' % (time.time(), what, json.dumps(msg)[:400])) |
| 67 | except OSError: |
| 68 | pass |
| 69 | |
| 70 | |
| 71 | def cfg(): |
| 72 | """The behaviour asked for, or the plain default.""" |
| 73 | out = {'chunks': 3, 'gap': False, 'huge': False, 'crash': False, 'delay_ms': 0, |
| 74 | 'caps': ['mock'], 'exit': 0, 'quiet_ms': 0, 'noise_ms': 0, 'mute': False} |
| 75 | try: |
| 76 | with open(CFG, encoding='utf-8') as fh: |
| 77 | out.update(json.load(fh)) |
| 78 | except (OSError, ValueError): |
| 79 | pass |
| 80 | return out |
| 81 | |
| 82 | |
| 83 | def read(): |
| 84 | """One frame from the browser, or None at end of stream.""" |
| 85 | head = sys.stdin.buffer.read(4) |
| 86 | if len(head) < 4: |
| 87 | return None |
| 88 | (n,) = struct.unpack('@I', head) |
| 89 | body = sys.stdin.buffer.read(n) |
| 90 | if len(body) < n: |
| 91 | return None |
| 92 | return json.loads(body.decode('utf-8')) |
| 93 | |
| 94 | |
| 95 | def send(msg): |
| 96 | """One frame back. Native byte order, which is what Chrome reads.""" |
| 97 | data = json.dumps(msg).encode('utf-8') |
| 98 | sys.stdout.buffer.write(struct.pack('@I', len(data))) |
| 99 | sys.stdout.buffer.write(data) |
| 100 | sys.stdout.buffer.flush() |
| 101 | note('->', msg) |
| 102 | |
| 103 | |
| 104 | def run(req, c): |
| 105 | """Answers one exec with invented output, on the schedule configured.""" |
| 106 | rid = req.get('id', '') |
| 107 | |
| 108 | # A host that says something the page does not understand, and nothing it |
| 109 | # does. Nothing here is a wire message: the point is that a page must not |
| 110 | # take an unrecognised frame as evidence that the command is alive. |
| 111 | if c['noise_ms']: |
| 112 | while True: |
| 113 | send({'t': 'noop', 'id': rid}) |
| 114 | time.sleep(c['noise_ms'] / 1000.0) |
| 115 | |
| 116 | send({'t': 'started', 'id': rid, 'pid': os.getpid()}) |
| 117 | |
| 118 | # The long silence at the start of a real build. It is not a hang, and a |
| 119 | # page that cannot tell the two apart kills the command it was asked to run. |
| 120 | if c['quiet_ms']: |
| 121 | time.sleep(c['quiet_ms'] / 1000.0) |
| 122 | |
| 123 | if c['crash']: |
| 124 | note('!!', {'crash': rid}) |
| 125 | sys.exit(1) |
| 126 | |
| 127 | if c['huge']: |
| 128 | # Over Chrome's 1 MB cap. Chrome drops the connection on seeing this |
| 129 | # and tells neither end why, which is the case the relay has to survive. |
| 130 | send({'t': 'chunk', 'id': rid, 'stream': 'out', 'seq': 1, 'data': 'x' * 1_200_000}) |
| 131 | return |
| 132 | |
| 133 | # Counted as it is sent, not declared. These two totals are the app's ONLY |
| 134 | # measure of whether output went missing between here and the model, so a |
| 135 | # hardcoded pair is an oracle that lies: they used to be 64 and 26 whatever |
| 136 | # was actually emitted, and three chunks of "line N of cargo test\n" is 63 |
| 137 | # bytes against a claimed 64, so every ordinary run in verify_handrun was |
| 138 | # handed "[some output did not arrive: 63 of 64 bytes on stdout, 27 of 26 on |
| 139 | # stderr]". The app was reporting this file's figure faithfully -- 27 of 26 |
| 140 | # is more than was asked for, which no byte counter can say about itself -- |
| 141 | # and the note cost a lane an hour on 2026-08-18. |
| 142 | out_bytes = 0 |
| 143 | err_bytes = 0 |
| 144 | |
| 145 | seq = 0 |
| 146 | for i in range(c['chunks']): |
| 147 | seq += 1 |
| 148 | # A deliberate hole: the relay must say so rather than hand the page a |
| 149 | # transcript that merely looks complete. |
| 150 | if c['gap'] and i == 1: |
| 151 | seq += 1 |
| 152 | data = 'line %d of %s\n' % (i + 1, ' '.join(req.get('argv', []))) |
| 153 | out_bytes += len(data.encode('utf-8')) |
| 154 | send({'t': 'chunk', 'id': rid, 'stream': 'out', 'seq': seq, 'data': data}) |
| 155 | if c['delay_ms']: |
| 156 | time.sleep(c['delay_ms'] / 1000.0) |
| 157 | |
| 158 | err = 'a word from standard error\n' |
| 159 | err_bytes += len(err.encode('utf-8')) |
| 160 | send({'t': 'chunk', 'id': rid, 'stream': 'err', 'seq': 1, 'data': err}) |
| 161 | send({'t': 'ended', 'id': rid, 'exit': c['exit'], 'timed_out': False, 'killed': False, |
| 162 | 'out_bytes': out_bytes, 'err_bytes': err_bytes}) |
| 163 | |
| 164 | |
| 165 | def main(): |
| 166 | note('--', {'started': sys.argv[1:]}) |
| 167 | c = cfg() |
| 168 | while True: |
| 169 | try: |
| 170 | req = read() |
| 171 | except ValueError as e: |
| 172 | send({'t': 'error', 'id': None, 'message': 'undecodable frame: %s' % e}) |
| 173 | return |
| 174 | if req is None: |
| 175 | note('--', {'stdin closed': True}) |
| 176 | return |
| 177 | note('<-', req) |
| 178 | |
| 179 | t = req.get('t') |
| 180 | if t == 'hello': |
| 181 | # A host that is there and never greets. Nothing is sent back at all, |
| 182 | # so the page's handshake deadline is the only thing that can end it. |
| 183 | if c['mute']: |
| 184 | note('--', {'mute': True}) |
| 185 | continue |
| 186 | send({'t': 'hello', 'proto': PROTO, 'host': 'daimond-hand (mock)', |
| 187 | 'version': '0.0.0-mock', 'os': 'linux', 'caps': c['caps']}) |
| 188 | elif t == 'exec': |
| 189 | run(req, c) |
| 190 | elif t == 'signal': |
| 191 | send({'t': 'ended', 'id': req.get('id', ''), 'exit': -1, 'timed_out': False, |
| 192 | 'killed': True, 'out_bytes': 0, 'err_bytes': 0}) |
| 193 | elif t == 'bye': |
| 194 | note('--', {'bye': True}) |
| 195 | return |
| 196 | else: |
| 197 | send({'t': 'error', 'id': req.get('id'), 'message': 'unknown message %r' % t}) |
| 198 | |
| 199 | |
| 200 | if __name__ == '__main__': |
| 201 | main() |