Oregami
Repositories/oxedyne/daimond

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
4It speaks the real framing -- a 4-byte native-endian length prefix followed by
5UTF-8 JSON, which is Chrome's format and not negotiable -- and the real
6messages, from ``hand/src/wire.rs``. It runs nothing. Every ``exec`` produces
7invented output on a schedule, which is exactly what is wanted: the relay's job
8is order, attribution and the shape of failure, and none of that needs a real
9process to exercise.
10
11It is also the only way to reach the failures that matter. A gap in the
12sequence, a message over Chrome's 1 MB limit, and a host that dies mid-command
13are all things a correct hand will never do, so a correct hand cannot be used to
14test what happens when they occur.
15
16Behaviour is read from ``mock_cfg.json`` beside this file, because Chrome gives
17a native messaging host no arguments of its own -- it passes the calling
18extension's origin as ``argv[1]`` and nothing else -- and the browser's
19environment 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
44Everything it receives and sends is appended to ``mock_host.log`` beside it, so
45a test can prove the relay sent ``bye`` when the page went away rather than
46leaving an orphan.
47"""
48
49import json
50import os
51import struct
52import sys
53import time
54
55HERE = os.path.dirname(os.path.abspath(__file__))
56CFG = os.path.join(HERE, 'mock_cfg.json')
57LOG = os.path.join(HERE, 'mock_host.log')
58
59PROTO = 1
60
61
62def 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
71def 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
83def 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
95def 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
104def 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
165def 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
200if __name__ == '__main__':
201 main()