oxedyne/daimond/dev/verify_presence.mjs
10.5 KiB, 1 run
created by r2519314175:597, 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 | // verify_presence.mjs — a presence beat travels off the content parcel, on a |
| 2 | // path that wakes nobody. |
| 3 | // |
| 4 | // The desktop-peer presence beat used to write a live wall-clock timestamp into |
| 5 | // the SYNC CONTENT PARCEL and re-upload the whole ~163K parcel every 45s, waking |
| 6 | // every other device forever and making the parcel a moving target that no fixed |
| 7 | // point could ever match (breaking dev/verify_reloadpush.mjs). The fix moves |
| 8 | // presence onto the gateway's OWN lightweight, non-waking route: |
| 9 | // |
| 10 | // - a beat is `POST /api/sync?presence=1` with `{device_id, name}`, answered |
| 11 | // with `{ok, now, presence:{ id:{name,last_seen}, ... }}`. It bumps no blob |
| 12 | // version and wakes nobody. |
| 13 | // - the ordinary pull (`GET /api/sync`) now also carries `presence` and `now`, |
| 14 | // adopted for free. |
| 15 | // - every last_seen is stamped in the SERVER clock; the client converts it into |
| 16 | // its own frame (DaimondPresence.ingest), so freshness checks that read |
| 17 | // Date.now() stay skew-immune and unchanged. |
| 18 | // |
| 19 | // This proves, with TWO real paired contexts (device a and device b): |
| 20 | // (a) a beat produces NO content /api/sync POST and does NOT wake device b; |
| 21 | // (b) device b, after device a beats, sees device a as an awake peer, named; |
| 22 | // (c) presence is NOT in the content parcel — it is byte-stable across beats; |
| 23 | // (d) a device that stops beating ages out of `awake` after the fresh window. |
| 24 | // |
| 25 | // Needs the dev stack: the app (DAIMOND_PORT), the mock, and a gateway on |
| 26 | // DAIMOND_GW_PORT that speaks the presence path. Presence rides the Pro-gated |
| 27 | // /api/sync door, so the account is granted Pro the one way the gateway trusts |
| 28 | // (dev/pro.mjs). |
| 29 | |
| 30 | import { open, chat, signInAs, newChat } from './harness.mjs'; |
| 31 | import { makePagePro } from './pro.mjs'; |
| 32 | import { GW_URL } from './ports.mjs'; |
| 33 | |
| 34 | const ok = [], bad = []; |
| 35 | const check = (name, pass, detail) => { |
| 36 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 37 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 38 | }; |
| 39 | |
| 40 | /// Count POSTs to /api/sync on this page, split by whether they are the presence |
| 41 | /// path (`?presence=1`) or an ordinary content push. A beat must show up only as |
| 42 | /// the former; a content push waking the other device is the latter. |
| 43 | const counter = (page) => { |
| 44 | const seen = []; |
| 45 | page.on('request', (r) => { |
| 46 | try { |
| 47 | const u = new URL(r.url()); |
| 48 | if (u.pathname === '/api/sync' && r.method() === 'POST') { |
| 49 | let n = 0; |
| 50 | try { n = (r.postData() || '').length; } catch (e) { /* unreadable */ } |
| 51 | seen.push({ presence: u.searchParams.get('presence') === '1', bytes: n }); |
| 52 | } |
| 53 | } catch (e) { /* not a URL this counts */ } |
| 54 | }); |
| 55 | return seen; |
| 56 | }; |
| 57 | |
| 58 | const content = (seen) => seen.filter((x) => !x.presence); |
| 59 | const presence = (seen) => seen.filter((x) => x.presence); |
| 60 | |
| 61 | const quiet = (pg) => pg.evaluate(() => window.DaimondSync.state().quiet); |
| 62 | const parcel = (pg) => pg.evaluate(async () => JSON.stringify(await window.DaimondSync.parcel())); |
| 63 | const wakes = (pg) => pg.evaluate(() => window.DaimondSync.wake().wakes); |
| 64 | const devId = (pg) => pg.evaluate(() => window.DaimondIdentity.deviceId()); |
| 65 | const devName = (pg) => pg.evaluate(() => |
| 66 | (window.DaimondIdentity.displayName && window.DaimondIdentity.displayName()) || ''); |
| 67 | |
| 68 | /// Wait until nothing is running and nothing is armed on this page. |
| 69 | const settle = async (pg, ms = 20000) => { |
| 70 | const t0 = Date.now(); |
| 71 | while (Date.now() - t0 < ms) { |
| 72 | if (await quiet(pg)) { await pg.waitForTimeout(1500); if (await quiet(pg)) return true; } |
| 73 | await pg.waitForTimeout(400); |
| 74 | } |
| 75 | return false; |
| 76 | }; |
| 77 | |
| 78 | /// One presence beat from a page, with this device's own id and name — exactly |
| 79 | /// what daimond.js's presenceTick passes. |
| 80 | const beat = (pg) => pg.evaluate(() => { |
| 81 | const id = window.DaimondIdentity.deviceId(); |
| 82 | const nm = (window.DaimondIdentity.displayName && window.DaimondIdentity.displayName()) || ''; |
| 83 | return window.DaimondSync.beatPresence(id, nm); |
| 84 | }); |
| 85 | |
| 86 | const a = await open({ name: 'preslead', signIn: true, connect: true, defaults: false }); |
| 87 | let b = null; |
| 88 | try { |
| 89 | await a.page.waitForFunction( |
| 90 | () => !!window.DaimondSync && !!window.DaimondPresence && window.DaimondGateway |
| 91 | && DaimondGateway.state().authed, |
| 92 | null, { timeout: 15000 }).catch(() => {}); |
| 93 | const pro = await makePagePro(a.page, new URL('../gateway', import.meta.url).pathname, GW_URL); |
| 94 | check('the account holds Pro, so the presence path is not refused before it is measured', |
| 95 | pro.pro === true, JSON.stringify(pro)); |
| 96 | |
| 97 | await newChat(a); |
| 98 | await chat(a, 'one turn, so there is a parcel to compare against at all'); |
| 99 | await settle(a.page); |
| 100 | await a.page.waitForTimeout(2000); |
| 101 | |
| 102 | // ── Pair device b to the SAME account ────────────────────────────── |
| 103 | // A second real context of one account, exactly as dev/verify_reloadpush.mjs |
| 104 | // check 6 pairs one: a mints a code, b redeems it, reloads onto the account, |
| 105 | // signs in and waits for a session. Its per-device id is minted locally and |
| 106 | // is DISTINCT from a's, which is what makes "the other device" meaningful. |
| 107 | b = await open({ name: 'presmate', signIn: false, connect: false }); |
| 108 | await b.page.waitForFunction(() => !!window.DaimondPairing, null, { timeout: 15000 }).catch(() => {}); |
| 109 | const code = await a.page.evaluate(() => DaimondPairing.create()); |
| 110 | await b.page.evaluate((c) => DaimondPairing.redeem(c), code.code); |
| 111 | await b.page.reload({ waitUntil: 'domcontentloaded' }); |
| 112 | await signInAs(b, 'preslead'); |
| 113 | await b.page.waitForFunction( |
| 114 | () => !!window.DaimondSync && !!window.DaimondPresence && window.DaimondGateway |
| 115 | && DaimondGateway.state().authed, |
| 116 | null, { timeout: 20000 }).catch(() => {}); |
| 117 | await b.page.waitForTimeout(4000); |
| 118 | await settle(b.page); |
| 119 | |
| 120 | const idA = await devId(a.page); |
| 121 | const idB = await devId(b.page); |
| 122 | const nameA = await devName(a.page); |
| 123 | check('the two paired devices hold DISTINCT per-device ids', |
| 124 | !!idA && !!idB && idA !== idB, JSON.stringify({ idA, idB })); |
| 125 | |
| 126 | // ── (a) A beat sends no content push and wakes nobody ────────────── |
| 127 | // The heart of the fix. Watch device a's POSTs while it beats, and device b's |
| 128 | // wake tally across those beats. A beat must be a presence POST and only that: |
| 129 | // zero content /api/sync POSTs, and device b's wakes unmoved. |
| 130 | await settle(a.page); |
| 131 | await b.page.waitForTimeout(2000); |
| 132 | const seenA = counter(a.page); |
| 133 | const wokeBefore = await wakes(b.page); |
| 134 | for (let i = 0; i < 3; i++) { await beat(a.page); await a.page.waitForTimeout(600); } |
| 135 | await b.page.waitForTimeout(4000); // long enough for a wake to have arrived if one was coming |
| 136 | const wokeAfter = await wakes(b.page); |
| 137 | check('a presence beat sends NO content sync push (only the presence path)', |
| 138 | content(seenA).length === 0, |
| 139 | content(seenA).length + ' content push(es), ' + presence(seenA).length + ' presence beat(s)'); |
| 140 | check('the beats really used the presence path (so the zero above means something)', |
| 141 | presence(seenA).length >= 1, presence(seenA).length + ' presence POST(s) seen'); |
| 142 | check('a presence beat does NOT wake the account\'s other device', |
| 143 | wokeAfter === wokeBefore, 'wakes ' + wokeBefore + ' -> ' + wokeAfter); |
| 144 | |
| 145 | // ── (b) Device b sees device a as an awake peer, named ───────────── |
| 146 | // After a has beaten, b reads the account's presence map (a plain GET, no |
| 147 | // beat) and must find device a among its awake peers, with the name a beat |
| 148 | // under. `awake` excludes self, so a — not b — is what should appear. |
| 149 | await beat(a.page); |
| 150 | await a.page.waitForTimeout(400); |
| 151 | await b.page.evaluate(() => window.DaimondSync.refreshPresence()); |
| 152 | const awakeSeenByB = await b.page.evaluate((self) => { |
| 153 | const list = window.DaimondPresence.awake(self, Date.now()) || []; |
| 154 | return list.map((x) => ({ deviceId: x.deviceId, name: x.name })); |
| 155 | }, idB); |
| 156 | check('device b sees device a as an awake peer after a beats', |
| 157 | awakeSeenByB.some((x) => x.deviceId === idA), JSON.stringify(awakeSeenByB)); |
| 158 | check('and the awake peer is named as device a beat', |
| 159 | awakeSeenByB.some((x) => x.deviceId === idA && x.name === nameA), |
| 160 | JSON.stringify({ want: { deviceId: idA, name: nameA }, got: awakeSeenByB })); |
| 161 | check('device b does not list ITSELF among its awake peers', |
| 162 | !awakeSeenByB.some((x) => x.deviceId === idB), JSON.stringify(awakeSeenByB)); |
| 163 | |
| 164 | // ── (c) Presence is NOT in the content parcel ────────────────────── |
| 165 | // The parcel is what a push sends and a pull merges. If presence still rode |
| 166 | // it, its moving last_seen would move the parcel on every beat; off it, two |
| 167 | // beats a second apart leave the parcel byte-identical. |
| 168 | await settle(a.page); |
| 169 | const parcelBefore = await parcel(a.page); |
| 170 | await beat(a.page); |
| 171 | await a.page.waitForTimeout(1000); |
| 172 | await beat(a.page); |
| 173 | const parcelAfter = await parcel(a.page); |
| 174 | check('the content parcel is byte-stable across two beats 1s apart (presence is off it)', |
| 175 | parcelBefore === parcelAfter, |
| 176 | parcelBefore === parcelAfter ? '' : 'it moved, so presence is still riding the parcel'); |
| 177 | check('and the parcel carries no presence section at all', |
| 178 | parcelAfter.indexOf('"presence"') === -1, |
| 179 | parcelAfter.indexOf('"presence"') === -1 ? '' : 'a "presence" key is in the parcel'); |
| 180 | |
| 181 | // ── (d) A device that stops beating ages out of awake ────────────── |
| 182 | // Simulated honestly on the real freshness logic: ingest an authoritative map |
| 183 | // (server clock === now, so no skew) holding one device whose last_seen is |
| 184 | // well past the fresh window and one within it. `awake` must drop the stale |
| 185 | // one and keep the fresh one — which is exactly what a peer that stopped |
| 186 | // beating looks like once its last beat ages out. |
| 187 | const aged = await b.page.evaluate(() => { |
| 188 | const now = Date.now(); |
| 189 | const FRESH = window.DaimondPresence.FRESH_MS; |
| 190 | window.DaimondPresence.ingest({ |
| 191 | 'stale-dev': { name: 'went-to-sleep', last_seen: now - (FRESH + 60000) }, |
| 192 | 'fresh-dev': { name: 'still-awake', last_seen: now - 1000 }, |
| 193 | }, now); |
| 194 | const list = (window.DaimondPresence.awake('viewer', now) || []).map((x) => x.deviceId); |
| 195 | return { hasStale: list.includes('stale-dev'), hasFresh: list.includes('fresh-dev') }; |
| 196 | }); |
| 197 | check('a device whose last beat aged past the fresh window is NOT awake', !aged.hasStale, |
| 198 | JSON.stringify(aged)); |
| 199 | check('a device still within the fresh window IS awake (the window discriminates)', |
| 200 | aged.hasFresh, JSON.stringify(aged)); |
| 201 | } catch (e) { |
| 202 | check('the run finished', false, String((e && e.stack) || e)); |
| 203 | } finally { |
| 204 | if (b) await b.close(); |
| 205 | await a.close(); |
| 206 | } |
| 207 | |
| 208 | console.log('\n' + ok.length + ' ok, ' + bad.length + ' failed'); |
| 209 | if (bad.length) { for (const l of bad) console.log(' FAILED: ' + l); } |
| 210 | process.exit(bad.length ? 1 : 0); |