aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/harness/boot_guard_probe.py
blob: 01751ba2e6f642711b8a3d75a976d8cbe6a17056 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
#!/usr/bin/env python3
"""
The shell cannot end up silently blank, and there is a way out of it.

A reader spent an evening on a white page: the shell arrived, every module came
from the browser's own store so not one request reached the hub, and nothing
rendered. Clearing the site's data fixed it — clearing the *cache*, three times,
did not, because the cache is not where a service worker or IndexedDB lives.

What is measured here is the guard, not the cause. Three things have to hold:
it says nothing when the application mounts; it appears, with the failure named
on screen, when the module graph does not link; and its reset button genuinely
empties this origin — a button that claims to and does not would be worse than
none.

The last case is why the guard is a classic script and not part of the module
graph: it has to survive the graph failing to link, which is exactly when a
reader needs it.

    boot_guard_probe.py

Prints JSON: one entry per case.
"""

import http.server
import json
import socketserver
import subprocess
import sys
import tempfile
import threading
import time
from pathlib import Path

STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static"
PORT = 8763
RECORDS = []
socketserver.TCPServer.allow_reuse_address = True

# The shell, as `webapp.py` builds it: the guard first, as a classic script,
# then the module graph. `?fail=1` asks for a module that is not there.
SHELL = r"""<!doctype html><html><head><meta charset=utf-8></head><body>
<div id="app"></div>
<script src="/boot-guard.js"></script>
<script type="module" src="/__case.js"></script>
</body></html>"""

MOUNTS = r"""// A successful mount: the guard must stay out of the way.
document.getElementById('app').appendChild(
  Object.assign(document.createElement('p'), { textContent: 'the application' }));
"""

BLOCKED = r"""<!doctype html><html><head><meta charset=utf-8></head><body>
<script type="module">
// Version 1, held open, as a second tab of this site running yesterday's build
// holds it. The shipped `openDB` wants version 2: the upgrade cannot proceed
// while this connection lives, and `indexedDB.open` then fires neither
// `success` nor `error`.
import { openDB } from '/hub-client.js';
const held = await new Promise((res, rej) => {
  const r = indexedDB.open('meshbay', 1);
  r.onupgradeneeded = () => r.result.createObjectStore('group_indexes', { keyPath: 'groupId' });
  r.onsuccess = () => res(r.result);
  r.onerror = () => rej(r.error);
});
const started = Date.now();
let outcome = 'never settled';
try {
  await openDB();
  outcome = 'resolved';
} catch (err) {
  outcome = 'rejected: ' + (err && err.message);
}
window.__result = { outcome, ms: Date.now() - started, held: !!held };
// Let go: the reset case below needs a database nobody is holding.
held.close();
</script></body></html>"""


PAGE = r"""<!doctype html><html><head><meta charset=utf-8></head>
<body style="margin:0"><div id="frames"></div><script>
const cases = [];
const post = (o) => fetch('/log', { method: 'POST', body: JSON.stringify(o) });
addEventListener('error', (e) => post({ error: 'page error: ' + (e.message || e) }));
addEventListener('unhandledrejection',
  (e) => post({ error: 'rejection: ' + ((e.reason && (e.reason.stack || e.reason.message)) || e.reason) }));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const add = (src) => {
  const f = document.createElement('iframe');
  f.src = src;
  f.style.cssText = 'width:420px;height:700px;border:0;display:block';
  document.getElementById('frames').appendChild(f);
  return new Promise((res) => { f.addEventListener('load', () => res(f)); });
};

(async () => {
  try {
    const ok = await add('/shell?case=mounts');
    const broken = await add('/shell?case=fails');
    // Started now, read after the wait: it settles well inside it.
    const blocked = await add('/blocked');

    const w = broken.contentWindow;
    w.localStorage.setItem('mb_auth', '{"token":"x"}');
    await new Promise((res) => {
      const r = w.indexedDB.open('meshbay', 1);
      r.onupgradeneeded = () => r.result.createObjectStore('group_indexes', { keyPath: 'groupId' });
      r.onsuccess = () => { r.result.close(); res(); };
      r.onerror = () => res();
    });

    // The guard looks once, late. Waiting it out is the measurement.
    await sleep(11000);

    const guardIn = (f) => {
      const h = f.contentDocument.getElementById('app');
      return { text: h ? h.textContent : '', buttons:
        [...f.contentDocument.querySelectorAll('#app button')].map((b) => b.textContent) };
    };
    const resetButton = (f) => [...f.contentDocument.querySelectorAll('#app button')]
      .find((x) => /r.initialis|reset/i.test(x.textContent));

    const a = guardIn(ok);
    cases.push({ case: 'the application mounted', text: a.text.slice(0, 80),
                 buttons: a.buttons });

    const b = guardIn(broken);
    cases.push({ case: 'the module graph did not link',
                 text: b.text.slice(0, 400), buttons: b.buttons });

    cases.push({ case: 'a blocked database upgrade gives up instead of hanging',
                 result: blocked.contentWindow.__result || 'never settled' });

    // ── a reset another tab is holding open ──────────────────────────────
    //
    // `deleteDatabase` waits for every other connection to close, silently: it
    // neither fails nor completes. A reset that does not watch for that
    // reloads into the state it has just claimed to clear.
    // No version: whatever the database is at now. Asking for a particular one
    // is how this fixture quietly stopped holding anything — the store had
    // moved to 2 underneath it and the open failed instead of connecting.
    const holder = await new Promise((res) => {
      const r = indexedDB.open('meshbay');
      r.onsuccess = () => res(r.result);
      r.onerror = () => res(null);
    });
    let reloadedAnyway = false;
    broken.addEventListener('load', () => { reloadedAnyway = true; }, { once: true });
    const r1 = resetButton(broken);
    let told = null;
    if (r1) {
      r1.click();
      await sleep(6000);
      const m = broken.contentDocument.querySelector('#app [data-blocked]');
      told = m ? m.getAttribute('data-blocked') : null;
    }
    cases.push({ case: 'a reset another tab is blocking says so',
                 clicked: !!r1, told, reloadedAnyway,
                 stillOffersReset: !!resetButton(broken) });

    // ── and with nothing in its way ──────────────────────────────────────
    if (holder) holder.close();
    const r2 = resetButton(broken);
    let after = null;
    if (r2) {
      const reloaded = new Promise((res) => broken.addEventListener('load', res, { once: true }));
      r2.click();
      await Promise.race([reloaded, sleep(9000)]);
      await sleep(1500);
      // Read from here, not from the frame: the frame is reloading, and a
      // window mid-navigation answers for whichever document happens to be
      // current. Same origin, so this is the same store.
      let dbs = null;
      try { dbs = (await indexedDB.databases()).map((d) => d.name); } catch (e) { dbs = 'n/a'; }
      after = { auth: localStorage.getItem('mb_auth'), dbs };
    }
    cases.push({ case: 'the reset button empties this origin', clicked: !!r2, after });

    fetch('/log', { method: 'POST', body: JSON.stringify({ cases }) });
  } catch (err) {
    fetch('/log', { method: 'POST',
      body: JSON.stringify({ error: String((err && err.stack) || err) }) });
  }
})();
</script></body></html>"""


class H(http.server.BaseHTTPRequestHandler):
    def log_message(self, *a):
        pass

    def do_POST(self):
        length = int(self.headers.get("Content-Length") or 0)
        if self.path == "/log":
            RECORDS.append(json.loads(self.rfile.read(length).decode()))
        else:
            self.rfile.read(length)
        self.send_response(204)
        self.end_headers()

    def _send(self, body: bytes, ctype: str, code: int = 200) -> None:
        self.send_response(code)
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def do_GET(self):
        path, _, query = self.path.partition("?")
        if path == "/":
            self._send(PAGE.encode(), "text/html; charset=utf-8")
        elif path == "/blocked":
            self._send(BLOCKED.encode(), "text/html; charset=utf-8")
        elif path == "/shell":
            self._send(SHELL.encode(), "text/html; charset=utf-8")
        elif path == "/__case.js":
            # The referring frame decides: one mounts, the other is missing —
            # a 404 on a module, which is what a graph that will not link looks
            # like from the outside.
            if "case=fails" in (self.headers.get("Referer") or ""):
                self._send(b"not here", "text/plain", 404)
            else:
                self._send(MOUNTS.encode(), "text/javascript")
        else:
            asset = (STATIC / path.lstrip("/")).resolve()
            if not str(asset).startswith(str(STATIC)) or not asset.is_file():
                self.send_response(404)
                self.end_headers()
                return
            self._send(asset.read_bytes(), "text/javascript")


def main() -> int:
    with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv:
        threading.Thread(target=srv.serve_forever, daemon=True).start()
        with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as profile:
            proc = subprocess.Popen(
                ["google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox",
                 f"--user-data-dir={profile}", "--window-size=900,900",
                 f"http://127.0.0.1:{PORT}/"],
                stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
            for _ in range(600):
                if RECORDS:
                    break
                time.sleep(0.1)
            proc.terminate()
            try:
                proc.wait(timeout=10)
            except subprocess.TimeoutExpired:
                proc.kill()
                proc.wait()
    if not RECORDS:
        print(json.dumps({"error": "no measurement"}), file=sys.stderr)
        return 1
    print(json.dumps(RECORDS[0], indent=1, ensure_ascii=False))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())