diff --git a/CLAUDE.md b/CLAUDE.md index 6540c18..3c75e40 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,8 @@ These exist because breaking one of them breaks a promise made to users. - **Never fake live.** Every reading carries its age. If the box is unreachable, the app shows the last value with its timestamp — never a stale number styled as current. -- **Freshness is two fields, never one.** `carrier` (relay, cache, none) and +- **Freshness is two fields, never one.** `carrier` (webrtc, relay, cache, + none — from the registry) and `srcState` (live, lagging, stale, down, never) are orthogonal. Collapsing them into a single enum cannot express "connected, but the inverter went quiet 40 seconds ago", which is the case users most need to see. @@ -72,7 +73,9 @@ These exist because breaking one of them breaks a promise made to users. from this app's catalogue in CI. - Tests sit beside the code as `*.test.ts`. Full-flow tests live in `tests/`. - Prefer explicit state over clever reactivity. A 1 Hz stream must not - recompute the tree; field cells are `$state.raw` and updated by fid. + re-render the tree: readings live in one session value so a frame is + consistent with itself, and everything derived from it memoises, so a + frame that changes nothing repaints nothing. ## Build and test diff --git a/src/App.svelte b/src/App.svelte index 400b806..3e00897 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -181,6 +181,10 @@ if (import.meta.env.DEV && id === SIM_SITE_ID) { // Dynamic import keeps the simulator out of production bundles entirely. void import('$lib/dev/simulated-site').then(({ attachSimulatedSite }) => { + // The import lands an await after it was asked for, and a sign-out in + // that gap replaces the store. A feed for the discarded one must not + // attach — nor overwrite the stopFeed that stops the current one. + if (store !== site) return stopFeed = attachSimulatedSite(store).stop }) } else { @@ -264,6 +268,16 @@ openFeed(site, pairedSiteId) } + /** + * The last sign-out failed and the home was put back. False otherwise. + * + * Held by the shell because the failure outlives the screen that met it: + * a failed leave remounts every view against the restored store, and a + * remounted Box has no memory of its own. The same shape as connectHelp — + * shell state passed down, so the screen can say what happens now. + */ + let leaveFailed = $state(false) + /** * Leave this home — or put it back, if the disk would not let go of it. * @@ -278,11 +292,15 @@ * * When the disk refuses, this phone still holds the home. So the shell puts * it back on screen and back on the wire rather than leaving a frozen view - * behind a message promising it still works, and rethrows so the Box screen - * can say what happened. + * behind a message promising it still works. The views are torn down the + * same way a successful leave tears them down — kept, they hold the stopped + * store, untracked on purpose, and never ask the restored one for anything. + * `leaveFailed` is how the remounted Box screen still knows what happened, + * because its own memory of the attempt goes with it. */ async function leave() { const id = site.siteId ?? siteId + leaveFailed = false try { const { leaveHome } = await import('$lib/state/leave') @@ -301,6 +319,12 @@ // the slow way — through the database, after painting nothing. void import('$lib/identity/pairing').then(({ setCurrentSite }) => setCurrentSite(id)) } + // The same reset the success path makes, for the same reason: a view + // kept from the old store would come back holding a home that has + // stopped. The route has not moved, so the screen being looked at is + // rebuilt in place against the restored store. + leaveFailed = true + seen = { plan: false, history: false, box: false } throw err } @@ -372,7 +396,12 @@ A view that has never been opened is still not built: `seen` gates the first mount, so the cost is paid once and never again. --> {#if seen.plan} @@ -388,6 +417,14 @@ {#await import('$views/History.svelte') then module} {@const History = module.default} + {:catch} + +

+ This screen didn't load — it will try again next time you open the app. +

{/await} {/if} @@ -398,7 +435,11 @@ {/if} @@ -450,6 +491,14 @@ min-height: 60vh; } + /* A view whose code never arrived. Quiet prose where the screen would be, + because a blank panel under a working tab bar reads as a broken app. */ + .load-note { + padding: var(--space-7) var(--space-4); + color: var(--fg-muted); + font-size: 13px; + } + /* `hidden` is the switch, so a view that is not showing costs no layout and is invisible to assistive technology — while keeping its element instances, its scroll position and whatever it had already loaded. */ diff --git a/src/App.svelte.test.ts b/src/App.svelte.test.ts index 874ec8c..f4d9e65 100644 --- a/src/App.svelte.test.ts +++ b/src/App.svelte.test.ts @@ -29,6 +29,16 @@ import { } from '$lib/identity/vault' import { CarrierBase, type Carrier, type CarrierStatus } from '$lib/carrier/carrier' import { encodeFrame, LANE_CONTROL } from '$lib/protocol/frame' +import { SiteStore } from '$lib/state/site.svelte' + +// jsdom has no ResizeObserver, and the History chart measures its own box +// with one. Nothing below depends on the width it would report. +class QuietResizeObserver { + observe(): void {} + unobserve(): void {} + disconnect(): void {} +} +globalThis.ResizeObserver ??= QuietResizeObserver as unknown as typeof ResizeObserver /* The relay, stubbed at the one seam the shell reaches it through. * @@ -209,6 +219,48 @@ describe('signing out, from the phone', () => { await screen.findByText(/Live via encrypted relay/i, undefined, { timeout: 4_000 }) expect(localStorage.getItem('ftw.site'), 'the launch pointer went with the home').toBe(SITE_ID) }) + + it('gives a kept view back to the restored store, not to the stopped one', async () => { + // The other half of putting the home back. Views read the store once, + // untracked on purpose, so a view kept across the failed leave holds the + // one that was stopped for good — History sat mounted and never asked + // the restored store anything, forty seconds past every retry window. + // The failed path has to reset `seen` the way the successful one does. + const askedHistory = vi.spyOn(SiteStore.prototype, 'history') + + await houseOnScreen() + ;(await screen.findByRole('button', { name: /^history$/i })).click() + await vi.waitFor(() => expect(askedHistory).toHaveBeenCalled(), { timeout: 4_000 }) + const oldStore = askedHistory.mock.contexts.at(-1) + const before = askedHistory.mock.calls.length + + const database = await db() + const clear = database.clear.bind(database) + vi.spyOn(database, 'clear').mockImplementation(async (store) => { + if (store === 'snapshot') await clear(store) + else throw new Error('quota exceeded') + }) + + await confirmSignOut() + // The failure is still said, by a screen that was itself remounted. + await screen.findByText(/still on this phone and still works/i, undefined, { timeout: 4_000 }) + + // Back to History, the way a person returns to it. + ;(await screen.findByRole('button', { name: /^history$/i })).click() + await vi.waitFor( + () => { + expect( + askedHistory.mock.calls.length, + 'the restored store was never asked for a window' + ).toBeGreaterThan(before) + expect( + askedHistory.mock.contexts.at(-1), + 'the ask went to the store that was stopped for good' + ).not.toBe(oldStore) + }, + { timeout: 4_000 } + ) + }) }) /* The state this whole screen was built for, and the one it used to get wrong. diff --git a/src/lib/carrier/carrier.ts b/src/lib/carrier/carrier.ts index 4e8465e..78d4ba8 100644 --- a/src/lib/carrier/carrier.ts +++ b/src/lib/carrier/carrier.ts @@ -86,16 +86,3 @@ export class CarrierBase { this.#statusHandlers.clear() } } - -/** - * How long a carrier must be quiet before the app stops calling it live. - * - * Deliberately longer than one missed frame. At 1 Hz a single dropped tick is - * normal on mobile; demoting on the first gap would make the freshness band - * flicker, and a flickering indicator teaches users to ignore it — which - * defeats the one thing it exists to do. - */ -export const DEGRADE_AFTER_MS = 4_000 - -/** Further silence, after which the app falls back to the cache carrier. */ -export const FALL_BACK_AFTER_MS = 10_000 diff --git a/src/lib/carrier/handshake-resilience.test.ts b/src/lib/carrier/handshake-resilience.test.ts index 3984d99..b227a81 100644 --- a/src/lib/carrier/handshake-resilience.test.ts +++ b/src/lib/carrier/handshake-resilience.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { NoiseCarrier } from './noise' import { CarrierBase, type Carrier, type CarrierStatus } from './carrier' -import { generateKeyPair } from '$lib/crypto/noise' +import { generateKeyPair, HandshakeState } from '$lib/crypto/noise' +import { NoiseTransport } from '$lib/crypto/transport' import type { CarrierState } from '$lib/protocol/types' /* A stray frame during the handshake must not end the carrier. @@ -53,7 +54,7 @@ function carrierUnderTest() { }) const seen: CarrierStatus[] = [] carrier.onStatus((s) => seen.push(s)) - return { inner, carrier, seen } + return { inner, carrier, seen, box } } describe('a handshake meeting somebody else’s frames', () => { @@ -103,4 +104,41 @@ describe('a handshake meeting somebody else’s frames', () => { expect(closed, 'silence never ended the handshake').toBeDefined() expect(closed && 'retryable' in closed && closed.retryable).toBe(true) }) + + it('asks again after the deadline, and a late-answering box still gets in', async () => { + // The deadline ends the first attempt retryably — but the socket is + // still open, and the inner carrier never re-emits 'open' on a socket + // that never dropped. The retry has to come from the Noise carrier + // itself, or a box rebooting through an update leaves the app closed + // until the epoch rotates. + const { inner, carrier, seen, box } = carrierUnderTest() + inner.open() + await vi.advanceTimersByTimeAsync(10) + expect(inner.sent.length).toBe(1) + + await vi.advanceTimersByTimeAsync(13_000) + expect(seen.some((s) => s.phase === 'closed' && s.retryable)).toBe(true) + + // A fresh message 1 goes out on its own, without the socket moving. + // Walked in small steps so the answer below lands while the newest + // attempt is still waiting, wherever the jitter put it. The step count + // is a bound, not a schedule: far more fake time than any first retry. + for (let i = 0; i < 100 && inner.sent.length === 1; i++) await vi.advanceTimersByTimeAsync(500) + expect(inner.sent.length, 'no second handshake was ever attempted').toBeGreaterThan(1) + + // The box comes back and answers the newest attempt; the session opens + // with no reload and no reconnect. + const responder = HandshakeState.responder({ staticKey: box }) + await responder.readMessage(inner.sent.at(-1)!) + inner.deliver(await responder.writeMessage()) + await vi.advanceTimersByTimeAsync(10) + expect(carrier.status.phase).toBe('open') + + // And frames flow: the box's first transport frame decrypts and surfaces. + const heard: Uint8Array[] = [] + carrier.onFrame((f) => heard.push(f)) + const boxTransport = new NoiseTransport(responder.split()) + inner.deliver(boxTransport.encrypt(Uint8Array.from([7, 7, 7]))) + expect(heard).toEqual([Uint8Array.from([7, 7, 7])]) + }) }) diff --git a/src/lib/carrier/noise.ts b/src/lib/carrier/noise.ts index abf4bc4..f621a1c 100644 --- a/src/lib/carrier/noise.ts +++ b/src/lib/carrier/noise.ts @@ -27,6 +27,19 @@ import { NoiseTransport } from '$lib/crypto/transport' */ const HANDSHAKE_DEADLINE_MS = 12_000 +/** + * Retry pacing for a handshake that timed out on a healthy socket. + * + * The inner carrier redials a dead socket on its own, but it never re-emits + * 'open' on one that stayed up — and a box rebooting through an update says + * nothing while its socket stands. Without a retry here, one silent handshake + * would leave the app closed until the epoch rotates. Full jitter, like the + * relay's dial backoff, and capped so a revoked phone — deliberate silence, + * forever — costs one 48-byte message a minute at worst. + */ +const HANDSHAKE_BACKOFF_BASE_MS = 3_000 +const HANDSHAKE_BACKOFF_CAP_MS = 60_000 + /** * Message 2 of Noise_IK: the responder's ephemeral public key and one AEAD * tag over an empty payload. Fixed by the pattern, so anything else on the @@ -73,6 +86,8 @@ export class NoiseCarrier extends CarrierBase implements Carrier { /** True once message 1 is out and we are waiting for the reply. */ #awaitingReply = false #deadline: ReturnType | undefined + #retry: ReturnType | undefined + #attempt = 0 #log: ((line: string) => void) | undefined /** Kept so each reconnection can start a fresh handshake from the same input. */ @@ -129,6 +144,8 @@ export class NoiseCarrier extends CarrierBase implements Carrier { if (this.#closed) return this.#closed = true + clearTimeout(this.#deadline) + this.#clearRetry() for (const u of this.#unsub) u() this.#unsub = [] @@ -145,6 +162,8 @@ export class NoiseCarrier extends CarrierBase implements Carrier { if (this.#closed) return if (s.phase === 'open') { + this.#attempt = 0 + this.#clearRetry() this.#beginHandshake() return } @@ -152,10 +171,13 @@ export class NoiseCarrier extends CarrierBase implements Carrier { // The inner carrier reconnects on its own, but a Noise session cannot // survive the gap: its keys are bound to one handshake and its counters // to one stream. So a drop restarts the handshake rather than resuming, - // which is also why split() must never be called twice. + // which is also why split() must never be called twice. A pending retry + // is cancelled too — its socket is gone, and the reconnect ends in an + // 'open' that starts a fresh handshake at once. this.#transport?.close() this.#transport = null this.#awaitingReply = false + this.#clearRetry() this.#setStatus(s) } @@ -223,6 +245,7 @@ export class NoiseCarrier extends CarrierBase implements Carrier { this.#transport = new NoiseTransport(handshake.split()) this.#awaitingReply = false this.#handshake = null + this.#attempt = 0 this.#setStatus({ phase: 'open', sinceMs: Date.now() }) }) .catch((err) => { @@ -242,9 +265,28 @@ export class NoiseCarrier extends CarrierBase implements Carrier { } #fail(reason: string, retryable = true): void { + if (this.#closed) return this.#transport?.close() this.#transport = null this.#awaitingReply = false this.#setStatus({ phase: 'closed', reason, retryable }) + + // A retryable failure on a socket that still stands is retried from here, + // because nowhere else will: nothing above consumes `retryable`, and the + // inner carrier only re-emits 'open' after an actual reconnect. Between + // attempts the status stays closed-and-retryable, which is the truth. + if (!retryable || this.#inner.status.phase !== 'open') return + this.#clearRetry() + const ceiling = Math.min(HANDSHAKE_BACKOFF_CAP_MS, HANDSHAKE_BACKOFF_BASE_MS * 2 ** this.#attempt) + this.#attempt = Math.min(this.#attempt + 1, 16) + this.#retry = setTimeout(() => { + this.#retry = undefined + this.#beginHandshake() + }, Math.random() * ceiling) + } + + #clearRetry(): void { + clearTimeout(this.#retry) + this.#retry = undefined } } diff --git a/src/lib/carrier/relay.test.ts b/src/lib/carrier/relay.test.ts index 29c2534..2a949b2 100644 --- a/src/lib/carrier/relay.test.ts +++ b/src/lib/carrier/relay.test.ts @@ -8,7 +8,7 @@ * carrier is expected to sort itself out without being asked. */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { RelayServer } from '../../../relay/src/server.ts' import { RelayCarrier, BACKOFF_CAP_MS } from './relay' import { rendezvousHandle } from './rendezvous' @@ -187,3 +187,83 @@ describe('the relay carrier', () => { expect(BACKOFF_CAP_MS).toBe(60_000) }) }) + +/** + * A socket the test opens, feeds and drops by hand, so a crash-looping relay + * can be played back without a server behind it. + */ +class StubSocket { + static all: StubSocket[] = [] + readyState = 0 + binaryType = '' + onopen: (() => void) | null = null + onmessage: ((ev: { data: unknown }) => void) | null = null + onclose: ((ev: { code: number; reason: string }) => void) | null = null + onerror: (() => void) | null = null + + constructor(readonly url: string) { + StubSocket.all.push(this) + } + + send(): void {} + close(): void { + this.readyState = 3 + } + + /** The relay accepts and the box is present. */ + accept(): void { + this.readyState = 1 + this.onopen?.() + this.onmessage?.({ data: 'ready' }) + } + frame(): void { + this.onmessage?.({ data: new Uint8Array(8).buffer }) + } + drop(): void { + this.readyState = 3 + this.onclose?.({ code: 1006, reason: '' }) + } +} + +describe('backing off from a relay that accepts and then drops', () => { + beforeEach(() => { + vi.useFakeTimers() + StubSocket.all = [] + }) + afterEach(() => vi.useRealTimers()) + + it('resets the dial backoff on a delivered frame, not on the accept', () => { + // random: () => 1 makes every delay its full ceiling, so the schedule is + // exact: 500, 1000, 2000... A relay stuck in a crash loop accepts each + // join and dies before a frame moves. Resetting the counter at the + // accept had every redial landing at the floor interval for as long as + // the loop lasted. + const carrier = new RelayCarrier({ + url: 'ws://relay.invalid', + secret: SECRET, + WebSocketImpl: StubSocket as unknown as typeof WebSocket, + random: () => 1, + }) + + StubSocket.all.at(-1)!.accept() + StubSocket.all.at(-1)!.drop() + vi.advanceTimersByTime(500) + expect(StubSocket.all.length).toBe(2) + + StubSocket.all.at(-1)!.accept() + StubSocket.all.at(-1)!.drop() + vi.advanceTimersByTime(999) + expect(StubSocket.all.length, 'an accept alone reset the backoff').toBe(2) + vi.advanceTimersByTime(1) + expect(StubSocket.all.length).toBe(3) + + // One frame through is proof the path works, and the counter heals. + StubSocket.all.at(-1)!.accept() + StubSocket.all.at(-1)!.frame() + StubSocket.all.at(-1)!.drop() + vi.advanceTimersByTime(500) + expect(StubSocket.all.length).toBe(4) + + carrier.close() + }) +}) diff --git a/src/lib/carrier/relay.ts b/src/lib/carrier/relay.ts index 0263aaa..1ad3225 100644 --- a/src/lib/carrier/relay.ts +++ b/src/lib/carrier/relay.ts @@ -182,7 +182,6 @@ export class RelayCarrier extends CarrierBase implements Carrier { #onMessage(ev: MessageEvent): void { if (typeof ev.data === 'string') { if (ev.data === CTRL_READY) { - this.#attempt = 0 this.#corrections = 0 this.#setStatus({ phase: 'open', sinceMs: this.#now() }) } else if (ev.data === CTRL_GONE) { @@ -193,6 +192,11 @@ export class RelayCarrier extends CarrierBase implements Carrier { } if (this.#status.phase !== 'open') return + // Only a delivered frame proves the path works, so this is where the dial + // backoff resets. Resetting on the accept let a relay that accepts and + // then dies keep us dialling at the floor interval for as long as it + // crash-looped. + this.#attempt = 0 this.emitFrame(new Uint8Array(ev.data as ArrayBuffer)) } diff --git a/src/lib/crypto/noise.test.ts b/src/lib/crypto/noise.test.ts index fb67a36..a9e58a2 100644 --- a/src/lib/crypto/noise.test.ts +++ b/src/lib/crypto/noise.test.ts @@ -236,6 +236,64 @@ describe('a relay cannot present itself as a box', () => { }) }) +describe('a stray frame cannot poison a waiting handshake', () => { + // The relay broadcasts every uplink frame to all streams in a room, and + // message 2 is exactly 48 bytes — two phones connecting at once read each + // other's replies. A reply that fails to authenticate must leave the + // handshake exactly as it was, or the genuine reply can never open it. + it('completes on the genuine message 2 after rejecting somebody else’s', async () => { + const { app, box } = pair() + const initiator = HandshakeState.initiator({ staticKey: app, remoteStatic: box.publicKey }) + const responder = HandshakeState.responder({ staticKey: box }) + + await responder.readMessage(await initiator.writeMessage()) + const m2 = await responder.writeMessage() + + // Another household's valid message 2: right shape, wrong session. + const otherBox = generateKeyPair() + const otherInitiator = HandshakeState.initiator({ + staticKey: generateKeyPair(), + remoteStatic: otherBox.publicKey, + }) + const otherResponder = HandshakeState.responder({ staticKey: otherBox }) + await otherResponder.readMessage(await otherInitiator.writeMessage()) + const stray = await otherResponder.writeMessage() + + await expect(initiator.readMessage(stray)).rejects.toThrow(NoiseError) + + // The genuine reply still opens the session, and a transport frame + // round-trips under the split keys. + await initiator.readMessage(m2) + const a = initiator.split() + const b = responder.split() + const ad = new Uint8Array(0) + const frame = utf8('grid_w=1200') + expect(bytesToHex(b.recv.decryptWithAd(ad, a.send.encryptWithAd(ad, frame)))).toBe(bytesToHex(frame)) + expect(bytesToHex(a.recv.decryptWithAd(ad, b.send.encryptWithAd(ad, frame)))).toBe(bytesToHex(frame)) + }) + + it('completes on the genuine message 1 after rejecting somebody else’s', async () => { + const { app, box } = pair() + const initiator = HandshakeState.initiator({ staticKey: app, remoteStatic: box.publicKey }) + const responder = HandshakeState.responder({ staticKey: box }) + const m1 = await initiator.writeMessage() + + // A message 1 aimed at some other box. Its first decryption cannot + // authenticate here, and it must not move the responder either. + const strayInitiator = HandshakeState.initiator({ + staticKey: generateKeyPair(), + remoteStatic: generateKeyPair().publicKey, + }) + await expect(responder.readMessage(await strayInitiator.writeMessage())).rejects.toThrow(NoiseError) + + await responder.readMessage(m1) + await initiator.readMessage(await responder.writeMessage()) + expect(initiator.isComplete).toBe(true) + expect(responder.isComplete).toBe(true) + expect(bytesToHex(initiator.split().handshakeHash)).toBe(bytesToHex(responder.split().handshakeHash)) + }) +}) + describe('state machine', () => { it('refuses to write out of turn', async () => { const { app, box } = pair() diff --git a/src/lib/crypto/noise.ts b/src/lib/crypto/noise.ts index 83e7d2c..1279d94 100644 --- a/src/lib/crypto/noise.ts +++ b/src/lib/crypto/noise.ts @@ -226,6 +226,18 @@ export class CipherState { } } + /** + * An independent copy: same key bytes, same counter, on its own buffer so + * destroying one cannot wipe the other. Exists for the handshake's + * commit-on-success reads; a transport cipher is never cloned. + */ + clone(): CipherState { + const copy = new CipherState(this.#key?.slice() ?? null) + copy.#nonce = this.#nonce + copy.#destroyed = this.#destroyed + return copy + } + /** Wipe the key. A closed session must not leave one reachable. */ destroy(): void { this.#key?.fill(0) @@ -282,6 +294,15 @@ class SymmetricState { return plaintext } + /** A copy to run a candidate message against. See the readMessage pair. */ + clone(): SymmetricState { + const copy = new SymmetricState() + copy.ck = this.ck.slice() + copy.h = this.h.slice() + copy.cipher = this.cipher.clone() + return copy + } + split(): [CipherState, CipherState] { const [k1, k2] = hkdfN(this.ck, new Uint8Array(0), 2) return [new CipherState(k1!.slice()), new CipherState(k2!.slice())] @@ -436,15 +457,22 @@ export class HandshakeState { throw new NoiseError(`handshake message 1 is ${message.length} bytes`, 'E_NOISE_MESSAGE') } - this.#re = message.subarray(0, DH_BYTES) - this.#sym.mixHash(this.#re) - this.#sym.mixKey(await this.#s.diffieHellman(this.#re)) + // Commit on success, as in #readMessage2: a message that fails to + // authenticate must leave no trace, or one stray frame ends every + // handshake that was still waiting for the real one. + const sym = this.#sym.clone() + const re = message.subarray(0, DH_BYTES) + sym.mixHash(re) + sym.mixKey(await this.#s.diffieHellman(re)) // Fails here when the initiator pinned somebody else's static key, which // is exactly the relay-impersonation case. - this.#rs = this.#sym.decryptAndHash(message.subarray(DH_BYTES, encStaticEnd)) - this.#sym.mixKey(await this.#s.diffieHellman(this.#rs)) - const payload = this.#sym.decryptAndHash(message.subarray(encStaticEnd)) + const rs = sym.decryptAndHash(message.subarray(DH_BYTES, encStaticEnd)) + sym.mixKey(await this.#s.diffieHellman(rs)) + const payload = sym.decryptAndHash(message.subarray(encStaticEnd)) + this.#sym = sym + this.#re = re + this.#rs = rs this.#step = 'write2' return payload } @@ -466,12 +494,20 @@ export class HandshakeState { throw new NoiseError(`handshake message 2 is ${message.length} bytes`, 'E_NOISE_MESSAGE') } - this.#re = message.subarray(0, DH_BYTES) - this.#sym.mixHash(this.#re) - this.#sym.mixKey(dh(this.#e!.secretKey, this.#re)) - this.#sym.mixKey(await this.#s.diffieHellman(this.#re)) - const payload = this.#sym.decryptAndHash(message.subarray(DH_BYTES)) - + // All mixing runs against a copy, which becomes the state only once the + // tag verifies. The relay broadcasts every frame in the room, and message + // 2 is exactly 48 bytes — two phones connecting at once read each other's + // replies. Mixing a stray one into the live state before the tag check + // would poison ck and h, and the genuine reply could never authenticate. + const sym = this.#sym.clone() + const re = message.subarray(0, DH_BYTES) + sym.mixHash(re) + sym.mixKey(dh(this.#e!.secretKey, re)) + sym.mixKey(await this.#s.diffieHellman(re)) + const payload = sym.decryptAndHash(message.subarray(DH_BYTES)) + + this.#sym = sym + this.#re = re this.#step = 'done' return payload } diff --git a/src/lib/format/explanation.ts b/src/lib/format/explanation.ts index b0062ca..ab058bb 100644 --- a/src/lib/format/explanation.ts +++ b/src/lib/format/explanation.ts @@ -21,6 +21,16 @@ export const FID = { BATTERY_W: 4, BATTERY_SOC: 5, LOAD_W: 6, + /** + * Which source feeds each power reading. Nothing in the app reads them + * yet, but this table is the app's copy of the registry's frozen set and + * tests/registry-contract.test.ts holds the two equal — a fid with no + * name here would need an exemption there, and an exemption list is the + * thing the registry exists to avoid. + */ + SRC_GRID: 7, + SRC_PV: 8, + SRC_BATTERY: 9, /** Present only on a site with a charger. Absent means no EV node. */ EV_W: 10, } as const diff --git a/src/lib/format/plan.test.ts b/src/lib/format/plan.test.ts index 6ca4d07..0887d25 100644 --- a/src/lib/format/plan.test.ts +++ b/src/lib/format/plan.test.ts @@ -63,6 +63,9 @@ describe('planHeadline', () => { const h = planHeadline(p, T0 + 60_000) expect(h.text).toMatch(/charging at 3.0 kW/) expect(h.text).toMatch(/Then it covers the house/) + // The unit must survive the sentence around it: "2.0 kW", never "2.0 kw" + // — a lowercased clause once dragged the unit down with it. + expect(h.text).not.toMatch(/\d\s*k?w\b/) expect(h.slotIndex).toBe(0) }) diff --git a/src/lib/format/plan.ts b/src/lib/format/plan.ts index 4ccd209..e09b549 100644 --- a/src/lib/format/plan.ts +++ b/src/lib/format/plan.ts @@ -109,7 +109,9 @@ export function planHeadline(plan: Plan | null, nowMs: number): PlanHeadline { const inMs = change.startMs - nowMs return { - text: `${describeSlot(now, 'now')} Then ${describeSlot(change, 'later').toLowerCase()} ${inWords(inMs)}.`, + // The 'later' wording already starts lowercase; lowercasing the whole + // clause here turned the unit into "170 w". + text: `${describeSlot(now, 'now')} Then ${describeSlot(change, 'later')} ${inWords(inMs)}.`, slotIndex: current, } } diff --git a/src/lib/protocol/contract.ts b/src/lib/protocol/contract.ts index eccc6e4..970e8e6 100644 --- a/src/lib/protocol/contract.ts +++ b/src/lib/protocol/contract.ts @@ -49,3 +49,9 @@ export const ROLE_LABELS: Record = { export function roleHasScope(role: string, scope: string): boolean { return (ROLE_SCOPES[role] ?? []).includes(scope) } + +/** The scope the box checks for OP_SET_MODE. */ +export const SCOPE_MODE_WRITE: Scope = 'ftw.mode.write' + +/** The capability a box offers when its optimizer accepts dispatch. */ +export const CAP_PLAN_DISPATCH = 'plan.dispatch' diff --git a/src/lib/protocol/session.ts b/src/lib/protocol/session.ts index 2529fd2..9169825 100644 --- a/src/lib/protocol/session.ts +++ b/src/lib/protocol/session.ts @@ -17,6 +17,7 @@ import { wireBytes, LANE_CONTROL, LANE_BULK, + BULK_BUCKETS, FrameError, } from './frame' import { @@ -188,7 +189,8 @@ interface PendingHistory { onChunk: (chunk: HistChunk) => void resolve: (end: HistEnd) => void reject: (err: Error) => void - timer: ReturnType + /** Unset only before the request is on the wire. See #armDeadline. */ + timer?: ReturnType } /** @@ -203,7 +205,8 @@ export const API_TIMEOUT_MS = 20_000 interface PendingApi { resolve: (res: ApiResponse) => void reject: (err: Error) => void - timer: ReturnType + /** Unset only before the request is on the wire. See #armDeadline. */ + timer?: ReturnType head: ApiHead | null chunks: Uint8Array[] bytes: number @@ -217,7 +220,8 @@ export const PLAN_TIMEOUT_MS = 8_000 interface PendingPlan { resolve: (plan: Plan) => void reject: (err: Error) => void - timer: ReturnType + /** Unset only before the request is on the wire. See #armDeadline. */ + timer?: ReturnType } /** A price window is one bulk message too, so it keeps the plan's deadline. */ @@ -226,7 +230,8 @@ export const PRICE_TIMEOUT_MS = 8_000 interface PendingPrices { resolve: (prices: Prices) => void reject: (err: Error) => void - timer: ReturnType + /** Unset only before the request is on the wire. See #armDeadline. */ + timer?: ReturnType } /** @@ -369,6 +374,8 @@ export class Session { #pendingPrices = new Map() #pendingApi = new Map() #pendingCmd = new Map() + /** The api queue's tail. Always settled-safe; see api(). */ + #apiTail: Promise = Promise.resolve() /** Set only while the box says it is starting. See BOOT_RETRY_MS. */ #bootRetry: ReturnType | undefined @@ -394,9 +401,31 @@ export class Session { * band says how long ago that was. Carrier stays 'cache', which is a * carrier and not a failure state: it is how the app has something honest * to show in its first frame. + * + * The cache read races the connect, and either may win. Landing while a + * carrier is mid-handshake, the cache still paints — that is the cold-start + * promise — but only the data: the phase, the carrier and the box's clock + * belong to the connection already under way. Nothing writes the carrier + * again on a connection that stays up, so a cache that overwrote it here + * left "can't reach your box" standing over a live stream for as long as + * the app was open. */ restore(patch: Partial): void { - if (this.#state.phase === 'streaming') return + const phase = this.#state.phase + if (phase === 'streaming') return + + if (phase === 'handshaking' || phase === 'subscribing' || phase === 'booting') { + // Fields already held mean a reconnect, and live readings are newer + // than any snapshot on disk. + if (this.#state.fields.size > 0) return + const data = { ...patch } + delete data.phase + delete data.carrier + delete data.uptimeMs + this.#patch(data) + return + } + this.#patch({ ...patch, phase: 'idle', carrier: 'cache' }) } @@ -486,13 +515,13 @@ export class Session { this.#nextRequestId = (this.#nextRequestId + 1) % 0xffffffff || 1 return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this.#pendingHistory.delete(id) - reject(new Error('history request timed out')) - }, HIST_TIMEOUT_MS) - - this.#pendingHistory.set(id, { onChunk, resolve, reject, timer }) + // Sent before anything is registered: a payload the largest bucket + // cannot carry throws out of the executor, rejecting the promise with + // no entry and no timer left behind for the deadline to sweep up. this.#sendBulk({ t: 'hist.query', id, b: query }) + + this.#pendingHistory.set(id, { onChunk, resolve, reject }) + this.#armDeadline(this.#pendingHistory, id, HIST_TIMEOUT_MS, 'history request timed out') }) } @@ -509,13 +538,12 @@ export class Session { this.#nextRequestId = (this.#nextRequestId + 1) % 0xffffffff || 1 return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this.#pendingPlan.delete(id) - reject(new Error('plan request timed out')) - }, PLAN_TIMEOUT_MS) - - this.#pendingPlan.set(id, { resolve, reject, timer }) + // Send first: a refused payload rejects through the executor and + // leaves nothing registered. See history(). this.#sendBulk({ t: 'plan.get', id }) + + this.#pendingPlan.set(id, { resolve, reject }) + this.#armDeadline(this.#pendingPlan, id, PLAN_TIMEOUT_MS, 'plan request timed out') }) } @@ -533,13 +561,12 @@ export class Session { this.#nextRequestId = (this.#nextRequestId + 1) % 0xffffffff || 1 return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this.#pendingPrices.delete(id) - reject(new Error('price request timed out')) - }, PRICE_TIMEOUT_MS) - - this.#pendingPrices.set(id, { resolve, reject, timer }) + // Send first: a refused payload rejects through the executor and + // leaves nothing registered. See history(). this.#sendBulk({ t: 'price.get', id, b: query }) + + this.#pendingPrices.set(id, { resolve, reject }) + this.#armDeadline(this.#pendingPrices, id, PRICE_TIMEOUT_MS, 'price request timed out') }) } @@ -554,28 +581,41 @@ export class Session { * an answer here, not a failure. Rejects with `ApiError` when the box sent a * stable code instead, and with a plain Error when the wire went away, the * deadline passed, or the answer arrived in pieces that do not fit together. + * + * One call is on the wire at a time, because that is how many the box + * serves: a second in flight is answered "busy", which costs whichever view + * lost the race a thirty-second backoff over a collision no view can see. + * The queue is chained on settle, not success, so a call that fails hands + * the wire to the one behind it — and each call's deadline is armed when it + * dispatches, because a place in the queue is not time spent waiting on the + * box. */ api(req: ApiReq): Promise { + const turn = this.#apiTail.then(() => this.#dispatchApi(req)) + this.#apiTail = turn.then( + () => undefined, + () => undefined + ) + return turn + } + + #dispatchApi(req: ApiReq): Promise { + // Checked at dispatch, not enqueue: what matters is whether the wire is + // there when this call's turn comes. A queued call whose predecessor was + // settled by the carrier going away meets the same answer it would have + // met in the pending map, now instead of at its deadline. if (!this.#carrier) return Promise.reject(new Error('no carrier')) + if (this.#carrier.status.phase === 'closed') { + return Promise.reject(new Error('carrier closed')) + } const id = this.#nextRequestId this.#nextRequestId = (this.#nextRequestId + 1) % 0xffffffff || 1 return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this.#pendingApi.delete(id) - reject(new Error('api request timed out')) - }, API_TIMEOUT_MS) - - this.#pendingApi.set(id, { - resolve, - reject, - timer, - head: null, - chunks: [], - bytes: 0, - nextSeq: 0, - }) + // Sent before anything is registered: a payload the largest bucket + // cannot carry throws out of the executor, rejecting the promise with + // no entry and no timer left behind for the deadline to sweep up. this.#sendBulk({ t: 'api.req', id, @@ -588,6 +628,16 @@ export class Session { ...(req.body ? { body: wireBytes(req.body) } : {}), }, }) + + this.#pendingApi.set(id, { + resolve, + reject, + head: null, + chunks: [], + bytes: 0, + nextSeq: 0, + }) + this.#armDeadline(this.#pendingApi, id, API_TIMEOUT_MS, 'api request timed out') }) } @@ -693,9 +743,20 @@ export class Session { case 'tick': this.#onTick(envelope.b as Tick) break - case 'hist.chunk': - this.#pendingHistory.get(envelope.id ?? -1)?.onChunk(envelope.b as HistChunk) + case 'hist.chunk': { + const hist = this.#pendingHistory.get(envelope.id ?? -1) + if (hist) { + // A window still arriving is not a window that has gone quiet. + this.#armDeadline( + this.#pendingHistory, + envelope.id!, + HIST_TIMEOUT_MS, + 'history request timed out' + ) + hist.onChunk(envelope.b as HistChunk) + } break + } case 'hist.end': this.#settleHistory(envelope.id, envelope.b as HistEnd) break @@ -780,6 +841,10 @@ export class Session { this.#patch({ phase: 'streaming', + // A snapshot only arrives over a carrier, so the stream names the one + // feeding it — whatever a cache paint that landed mid-handshake may + // have said in the meantime. + ...(this.#carrier ? { carrier: this.#carrier.kind } : {}), uptimeMs: b.uptimeMs, controlRev: b.controlRev, dict: b.dict, @@ -872,6 +937,7 @@ export class Session { // rather than allowed to rewrite what the caller will be told. if (pending.head !== null) return pending.head = head + this.#armDeadline(this.#pendingApi, id!, API_TIMEOUT_MS, 'api request timed out') } #onApiChunk(id: number | undefined, chunk: ApiChunk): void { @@ -891,6 +957,7 @@ export class Session { pending.nextSeq += 1 pending.chunks.push(chunk.data) pending.bytes += chunk.data.length + this.#armDeadline(this.#pendingApi, id!, API_TIMEOUT_MS, 'api request timed out') } /** @@ -1010,7 +1077,7 @@ export class Session { if (!this.#carrier) return // Sized by trial: the encoder refuses to grow a bucket, so the frame is // built once and stepped up rather than guessed at from the object. - for (const bucket of [1024, 4096, 16384]) { + for (const bucket of BULK_BUCKETS) { try { this.#carrier.send(encodeFrame({ lane: LANE_BULK, flags: 0, envelope }, bucket)) return @@ -1021,6 +1088,30 @@ export class Session { throw new FrameError('bulk payload exceeds the largest bucket', 'E_FRAME_EXCEEDS_BUCKET') } + /** + * Arm — or push back — a request's deadline. + * + * The deadline measures silence, not total time. A multi-megabyte answer + * arriving steadily in chunks is a request that is working, and a deadline + * armed once at dispatch would kill it mid-flow while the box kept + * streaming into an id nobody held any more. So every head and chunk + * re-arms it, and what expires is a wire that has gone quiet. + */ + #armDeadline

; reject: (err: Error) => void }>( + map: Map, + id: number, + ms: number, + message: string + ): void { + const pending = map.get(id) + if (!pending) return + clearTimeout(pending.timer) + pending.timer = setTimeout(() => { + map.delete(id) + pending.reject(new Error(message)) + }, ms) + } + #detach(): void { clearTimeout(this.#bootRetry) for (const u of this.#unsub) u() diff --git a/src/lib/state/age.test.ts b/src/lib/state/age.test.ts index 7476b22..352b20c 100644 --- a/src/lib/state/age.test.ts +++ b/src/lib/state/age.test.ts @@ -3,6 +3,8 @@ import 'fake-indexeddb/auto' import { Session } from '$lib/protocol/session' import { LoopbackCarrier } from '$lib/carrier/loopback' import { SimBox } from '$lib/sim/box' +import { saveSnapshot } from '$lib/store/snapshot' +import { FID } from '$lib/format/explanation' import type { SourceState } from '$lib/protocol/types' /* Age is a claim, and a wrong one is the one thing this app must never make. @@ -135,3 +137,46 @@ describe('the age keeps moving when the stream stops', () => { site.destroy() }) }) + +/* The cached view's age, when the snapshot was already behind at capture. + * + * Time on the shelf is only half the answer. A snapshot written while the + * inverter had been quiet for a minute holds readings a minute older than the + * write stamp says, and the source rows in the snapshot carry exactly that — + * lastOk against the box's clock at capture. Counting the shelf alone + * reported those readings younger than they ever were. + */ +describe('the age of a cached view', () => { + it('carries the staleness the readings already had at capture', async () => { + // The meter's last answer was 40 s behind the box's clock when the + // snapshot was written, and the snapshot has sat two minutes since. + await saveSnapshot({ + siteId: 'box-stale-capture', + savedAtMs: Date.now() - 120_000, + uptimeMs: 500_000, + fields: { [FID.GRID_W]: 1_200 }, + sources: { + meter: { + kind: 'driver', + name: 'meter', + lastOkMs: 460_000, + staleAfterMs: 5_000, + state: 'stale', + }, + }, + dispatchBlockedBy: [], + dict: { [String(FID.GRID_W)]: { name: 'grid_w', unit: 'W', srcId: 'meter' } }, + controlRev: 1, + }) + + const { SiteStore } = await import('./site.svelte') + const site = new SiteStore('test') + await site.start('box-stale-capture') + + expect(site.carrier, 'nothing restored, so the rest proves nothing').toBe('cache') + // Two minutes on the shelf plus the 40 s the meter already lagged. + expect(site.ageMs).toBeGreaterThanOrEqual(160_000) + + site.destroy() + }) +}) diff --git a/src/lib/state/ask.svelte.ts b/src/lib/state/ask.svelte.ts index 88b3d65..c8cad9f 100644 Binary files a/src/lib/state/ask.svelte.ts and b/src/lib/state/ask.svelte.ts differ diff --git a/src/lib/state/flow.test.ts b/src/lib/state/flow.test.ts index 68bdddc..7c8177c 100644 --- a/src/lib/state/flow.test.ts +++ b/src/lib/state/flow.test.ts @@ -76,4 +76,44 @@ describe('flowReadings', () => { const grid = r.planets.find((p) => p.id === 'grid')! expect(grid.sub).toBe('no data') }) + + it('never hands the component a negative number to draw', () => { + // The wire's sign is direction — positive into the site, negative out — + // and the hero renders kw as text, so a sign passed through here is a raw + // minus on screen: "-3.40 kW" over "exporting". Direction travels as + // toHub and as the sub line's word, never as the number's sign. + const everythingOutward = flowReadings( + fields([ + [FID.GRID_W, -3_400], + [FID.PV_W, -2_300], + [FID.BATTERY_W, -2_000], + [FID.EV_W, 0], + [FID.LOAD_W, 900], + ]) + ) + for (const p of everythingOutward.planets) { + expect(p.kw, `${p.id} carried the wire's sign into the hero`).toBeGreaterThanOrEqual(0) + } + }) + + it('says which way the battery is moving, in the component’s own words', () => { + // A battery moves power both ways, so unlike solar the magnitude alone + // cannot say which. Without the word, "2.00 kW" is a battery doing + // something unstated. + const discharging = flowReadings(fields([[FID.BATTERY_W, -2_000]])).planets.find( + (p) => p.id === 'battery' + )! + expect(discharging.kw).toBeCloseTo(2) + expect(discharging.sub).toBe('discharging') + + const charging = flowReadings(fields([[FID.BATTERY_W, 1_800]])).planets.find( + (p) => p.id === 'battery' + )! + expect(charging.sub).toBe('charging') + + const resting = flowReadings(fields([[FID.BATTERY_W, 0]])).planets.find( + (p) => p.id === 'battery' + )! + expect(resting.sub).toBe('idle') + }) }) diff --git a/src/lib/state/flow.ts b/src/lib/state/flow.ts index 5771ea0..3a3c803 100644 --- a/src/lib/state/flow.ts +++ b/src/lib/state/flow.ts @@ -50,7 +50,10 @@ export function flowReadings(fields: ReadonlyMap): FlowReadings kw: 0, toHub: true, color: 'var(--fg-muted)', sub: 'no data', clickable: false, }) } else { - const g = gridW / 1000 + // Magnitude only: the sign is wire convention, and the sub line already + // carries the direction. A minus over "exporting" is the raw sign the UI + // never shows. + const g = Math.abs(gridW) / 1000 planets.push({ id: 'grid', corner: 'bottom-left', title: 'GRID', role: 'grid', kw: g, toHub: gridW >= 0, @@ -77,15 +80,18 @@ export function flowReadings(fields: ReadonlyMap): FlowReadings const batteryW = fields.get(FID.BATTERY_W) if (batteryW !== undefined) { - const b = batteryW / 1000 + // Magnitude only, and the direction spelled out in the sub. A battery + // moves power both ways, so unlike solar the number alone cannot say + // which — and a raw minus is the one thing the UI never shows. + const b = Math.abs(batteryW) / 1000 const socPermille = fields.get(FID.BATTERY_SOC) planets.push({ id: 'battery', corner: 'top-right', title: 'BATTERY', role: 'battery', kw: b, toHub: batteryW < 0, - // Direction carried by the value's colour: charge green (filling), + // Direction also in the value's colour: charge green (filling), // discharge red (draining), idle the battery's identity cyan. color: idle(batteryW) ? 'var(--cyan)' : batteryW >= 0 ? 'var(--green-e)' : 'var(--red-e)', - sub: '', + sub: idle(batteryW) ? 'idle' : batteryW >= 0 ? 'charging' : 'discharging', soc: socPermille === undefined ? null : Math.round(socPermille / 10), clickable: false, }) diff --git a/src/lib/state/history.svelte.ts b/src/lib/state/history.svelte.ts index fc1ce38..1a7d3d0 100644 --- a/src/lib/state/history.svelte.ts +++ b/src/lib/state/history.svelte.ts @@ -13,7 +13,6 @@ import type { HistChunk, HistEnd, Resolution } from '$lib/protocol/messages' import { - RESOLUTIONS, planQuery, assembleFrame, clipFrame, @@ -107,6 +106,11 @@ export class HistoryStore { * away, on the screen where the wire is busiest. */ select(range: RangeKey): void { + // The cursor is an index into the current frame, and the same index in + // another range is another moment — against a longer step it can even + // name a time in the future. There is no honest sample to move it to + // until the new frame exists, so it lets go. + if (range !== this.range) this.cursor = null this.range = range } @@ -175,7 +179,7 @@ export class HistoryStore { this.loaded = true show() - if (siteId) void pruneTiles(siteId, toMs - RESOLUTIONS[end.resActual].retentionMs) + if (siteId) void pruneTiles(siteId, toMs) } catch (err) { // A reply for a range the user has already moved off is not this // range's news, and it is not a reason to ask for this one again. diff --git a/src/lib/state/history.test.ts b/src/lib/state/history.test.ts new file mode 100644 index 0000000..80263ac --- /dev/null +++ b/src/lib/state/history.test.ts @@ -0,0 +1,31 @@ +/* The cursor, as the range moves under it. + * + * The cursor is an index into the current frame. The same index against + * another range's frame names another moment — against a longer step it can + * even name a time in the future, and the readout dates a sample nobody is + * pointing at. + */ + +import { describe, it, expect } from 'vitest' +import { HistoryStore } from './history.svelte' +import { SiteStore } from './site.svelte' + +describe('the cursor across a range change', () => { + it('does not survive into a frame where its index means a different time', () => { + const store = new HistoryStore(new SiteStore('test')) + store.cursor = 200 + + store.select('30d') + + expect(store.cursor, 'an index into the old frame was kept against the new one').toBeNull() + }) + + it('stays put when the range does not actually change', () => { + const store = new HistoryStore(new SiteStore('test')) + store.cursor = 12 + + store.select('24h') + + expect(store.cursor).toBe(12) + }) +}) diff --git a/src/lib/state/plan.svelte.ts b/src/lib/state/plan.svelte.ts index 15ee905..9d0d205 100644 --- a/src/lib/state/plan.svelte.ts +++ b/src/lib/state/plan.svelte.ts @@ -9,12 +9,10 @@ import type { Plan, SiteMode, CmdResult, ModeInfo } from '$lib/protocol/messages' import { OP_SET_MODE } from '$lib/protocol/messages' import { CommandError } from '$lib/protocol/session' +import { CAP_PLAN_DISPATCH, SCOPE_MODE_WRITE } from '$lib/protocol/contract' import type { SiteStore } from './site.svelte' import { FID } from '$lib/format/explanation' -/** From contract/registry.yaml, and the scope the box checks for OP_SET_MODE. */ -const SCOPE_MODE_WRITE = 'ftw.mode.write' - /** What the user sees while an intent is in flight. */ export type CommandState = | { kind: 'idle' } @@ -30,7 +28,18 @@ export class PlanStore { #site: SiteStore #timer: ReturnType | null = null - plan = $state(null) + /** + * What the box intends to do, read where the session keeps it. + * + * The session holds the one copy, because a plan does not only arrive as an + * answer: the box replans after a mode change made from any phone and + * pushes the result unasked, with no request id. A copy held here caught + * only the answers, so a long-lived connection drained to "no plan" while + * the session's plan moved on without it. + */ + get plan(): Plan | null { + return this.#site.session.plan + } /** * Bumped when something other than the session wants the plan again. @@ -105,7 +114,7 @@ export class PlanStore { * gets this wrong is merely rude rather than unsafe. */ get canControl(): boolean { - return this.#site.session.caps.has('plan.dispatch') && this.#hasModeScope + return this.#site.session.caps.has(CAP_PLAN_DISPATCH) && this.#hasModeScope } /** @@ -160,17 +169,19 @@ export class PlanStore { } /** - * Fetch the plan, and say so when the box could not send one. + * Ask for the plan, and say so when the box could not send one. * - * Rejects on failure as well as saying it, because the caller that heals - * this — `askWhenLive` — has no other way to tell an answer from a failure - * the store swallowed. The view keeps whatever plan it had either way. + * The answer itself lands on session state, where `plan` reads it — this + * only asks and owns the failure sentence. Rejects on failure as well as + * saying it, because the caller that heals this — `askWhenLive` — has no + * other way to tell an answer from a failure the store swallowed. The view + * keeps whatever plan it had either way. */ async load(): Promise { this.loading = true this.problem = null try { - this.plan = await this.#site.plan() + await this.#site.plan() } catch (err) { // A plan the box could not send is not a broken app. What happens now // is that the app asks again on its own, so that is what it says — the diff --git a/src/lib/state/plan.test.ts b/src/lib/state/plan.test.ts new file mode 100644 index 0000000..1aa8b4e --- /dev/null +++ b/src/lib/state/plan.test.ts @@ -0,0 +1,40 @@ +/* The plan the box pushes unasked. + * + * A plan does not only arrive as an answer. The box replans after a mode + * change made from any phone and pushes the result with no request id, + * because nobody on this connection asked. The session keeps that copy; a + * store holding its own caught only the answers, so a long-lived connection + * drained to "no plan for right now" while the box's intent moved on. + */ + +import { describe, it, expect, vi } from 'vitest' +import 'fake-indexeddb/auto' +import { SiteStore } from './site.svelte' +import { PlanStore } from './plan.svelte' +import { LoopbackCarrier } from '$lib/carrier/loopback' +import { SimBox } from '$lib/sim/box' +import { OP_SET_MODE } from '$lib/protocol/messages' + +describe('a replan this phone never asked for', () => { + it('reaches the plan on screen', async () => { + const box = new SimBox({}) + const site = new SiteStore('test') + const store = new PlanStore(site) + site.connect(new LoopbackCarrier(box, { latencyMs: 0 })) + await vi.waitFor(() => expect(site.session.phase).toBe('streaming'), { timeout: 2_000 }) + + // Nothing has been asked for, and nothing has been pushed. + expect(store.plan).toBeNull() + + // A mode change straight over the wire — as another phone's would land — + // never touching this store's load(). The box replans and pushes the + // result with no pending id to claim it. + const result = await site.command(OP_SET_MODE, { mode: 'planner_self' }) + expect(result.state, 'the mode change failed, so no replan is coming').toBe('applied') + + await vi.waitFor(() => expect(store.plan).not.toBeNull(), { timeout: 2_000 }) + expect(store.plan!.rev).toBeGreaterThan(1) + + site.destroy() + }) +}) diff --git a/src/lib/state/site.svelte.ts b/src/lib/state/site.svelte.ts index eb55c1c..4ba6357 100644 --- a/src/lib/state/site.svelte.ts +++ b/src/lib/state/site.svelte.ts @@ -46,6 +46,16 @@ const NOW_FIDS: readonly Fid[] = [ FID.EV_W, ] +/** + * How long a 1 Hz stream may be quiet before the silence is reported. + * + * A stream is always a fraction of a second behind its last frame, and on + * mobile a single dropped tick is normal. Reporting that would make a healthy + * view flicker between "now" and "1s ago" forever, which reads as a fault + * where there is none. Past a couple of beats the silence is real. + */ +const STREAM_QUIET_AFTER_MS = 3_000 + /** * The sources behind the readings on the Now view, named by the box. * @@ -74,6 +84,8 @@ export class SiteStore { #writer = new SnapshotWriter() #siteId: string | null = null #markedLive = false + /** Set for good in destroy(). A dead store must not accept a carrier. */ + #destroyed = false /** Wall clock when the last frame arrived. Null until one does. */ #lastFrameAtMs = $state(null) @@ -133,6 +145,18 @@ export class SiteStore { * read itself was already started by the inline script in index.html. */ async start(siteId: string): Promise { + // Repointed at a different home. The subscriber above tags every + // streaming frame with #siteId, so the old session has to be gone before + // the id moves — one frame in the gap and the old house is sealed to disk + // under the new one's id. The per-site bookkeeping resets with it, so the + // new home's cache restores exactly as on a launch. A launch and a + // same-home restart take neither branch. + if (this.#siteId !== null && this.#siteId !== siteId) { + this.#session.close() + this.#lastFrameAtMs = null + this.cachedAtMs = null + this.#markedLive = false + } this.#siteId = siteId const cached = (await takeBootSnapshot()) ?? (await loadSnapshot(siteId)) @@ -301,7 +325,16 @@ export class SiteStore { */ get ageMs(): number { if (this.carrier === 'cache' && this.cachedAtMs !== null) { - return Date.now() - this.cachedAtMs + // The snapshot's readings were not new when they were written: each + // source row carries how far its last answer already lagged the box's + // clock at capture, and time on the shelf alone understates the age by + // exactly that. NaN rows — stamps from another boot — mean unknown, + // and unknown must not make the number smaller. + const shelfMs = Date.now() - this.cachedAtMs + const atCapture = nowSourceIds(this.session) + .map((s) => this.#session.ageOf(s)) + .filter((a) => !Number.isNaN(a)) + return atCapture.length > 0 ? shelfMs + Math.max(...atCapture) : shelfMs } const ages = nowSourceIds(this.session) .map((s) => this.#session.ageOf(s)) @@ -328,11 +361,7 @@ export class SiteStore { const at = this.#lastFrameAtMs if (at === null) return 0 const since = this.#now - at - // A 1 Hz stream is always a fraction of a second behind its last frame. - // Reporting that would make a healthy view flicker between "now" and "1s - // ago" forever, which reads as a fault where there is none. Past a couple - // of beats the silence is real and every millisecond of it counts. - if (since < 3_000) return 0 + if (since < STREAM_QUIET_AFTER_MS) return 0 return since } @@ -360,6 +389,15 @@ export class SiteStore { } connect(carrier: Carrier): void { + // A carrier can finish connecting after the store it was meant for is + // gone — sign out during a slow connect. Handing it to the session would + // start a live, self-reconnecting stream to the home this phone just + // left, unreferenced and unclosable. Closed rather than dropped, because + // a dropped carrier leaks its socket. + if (this.#destroyed) { + carrier.close() + return + } this.#session.connect(carrier) } @@ -369,6 +407,7 @@ export class SiteStore { } destroy(): void { + this.#destroyed = true if (this.#ticker !== null) { clearInterval(this.#ticker) this.#ticker = null diff --git a/src/lib/state/site.test.ts b/src/lib/state/site.test.ts new file mode 100644 index 0000000..ce6a0d9 --- /dev/null +++ b/src/lib/state/site.test.ts @@ -0,0 +1,75 @@ +/* The store and the home it points at. + * + * Two ways the pointer and the stream disagree, and both end with this phone + * doing something for a home it should not: + * + * - start() repoints the store while the old home is still streaming. The + * subscriber tags every streaming frame with the current site id, so one + * frame in the gap seals house A's readings to disk under house B's id — + * and B's next cold start paints A's kitchen. + * - a carrier finishes connecting after the store was destroyed. Handed to + * the session, it starts a live, self-reconnecting stream to the home + * this phone just signed out of, unreferenced and unclosable. + * + * Everything here is real: a Session, a SimBox, the loopback carrier. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import 'fake-indexeddb/auto' +import { SiteStore } from './site.svelte' +import { LoopbackCarrier } from '$lib/carrier/loopback' +import { SimBox } from '$lib/sim/box' +import { db } from '$lib/store/db' +import { loadSnapshot } from '$lib/store/snapshot' + +beforeEach(async () => { + const database = await db() + for (const store of ['sites', 'snapshot', 'tiles', 'meta', 'keys'] as const) { + await database.clear(store) + } +}) + +describe('a store repointed at another home mid-stream', () => { + it('never seals the old house under the new id', async () => { + const box = new SimBox({}) + const site = new SiteStore('test') + await site.start('home-a') + site.connect(new LoopbackCarrier(box, { latencyMs: 0 })) + await vi.waitFor(() => expect(site.session.phase).toBe('streaming'), { timeout: 2_000 }) + + // Repointed without a disconnect: signing out of A and into B while A's + // frames are still in the air. + await site.start('home-b') + + // One more frame from A's box, and the flush the shell makes on every + // visibilitychange. + box.tick() + await new Promise((r) => setTimeout(r, 20)) + await site.persistNow() + + expect( + await loadSnapshot('home-b'), + "house A's readings were sealed under house B's id" + ).toBeNull() + + site.destroy() + }) +}) + +describe('a carrier that finishes connecting after sign-out', () => { + it('is closed, not adopted', async () => { + const box = new SimBox({}) + const spoken = vi.spyOn(box, 'receive') + + const site = new SiteStore('test') + site.destroy() + + // The connect that was in flight when the user signed out. + const carrier = new LoopbackCarrier(box, { latencyMs: 0 }) + site.connect(carrier) + await new Promise((r) => setTimeout(r, 50)) + + expect(carrier.status.phase, 'a store nothing owns kept a live carrier').toBe('closed') + expect(spoken, 'the dead store still spoke to the box').not.toHaveBeenCalled() + }) +}) diff --git a/src/lib/store/tiles.test.ts b/src/lib/store/tiles.test.ts index bea5032..2ca8449 100644 --- a/src/lib/store/tiles.test.ts +++ b/src/lib/store/tiles.test.ts @@ -13,6 +13,7 @@ import { packColumns } from '$lib/protocol/history' import type { HistChunk } from '$lib/protocol/messages' const SITE = 'sim-0001' +const DAY_MS = 86_400_000 function chunk(overrides: Partial = {}): HistChunk { return { @@ -84,9 +85,36 @@ describe('history tiles on disk', () => { await saveTile(SITE, chunk()) await saveTile(SITE, chunk({ tileId: '5m/1/3062', startMs: 3062 * 43_200_000 })) - const dropped = await pruneTiles(SITE, 3062 * 43_200_000) + // A month past the newer tile: the older one has left the box's 5m + // retention, the newer one sits exactly on its edge. + const dropped = await pruneTiles(SITE, 3062 * 43_200_000 + 30 * DAY_MS) expect(dropped).toBe(1) expect((await loadTiles(SITE, ['5m/1/3061', '5m/1/3062'])).size).toBe(1) }) + + it('prunes each resolution by its own retention', async () => { + // The box keeps a month of 5m beside two years of 1h, and both answers + // prune the same store. Judged by one cutoff, a 5m answer's month would + // evict hour tiles deep inside their own two years — the year chart + // self-destructing every time the day chart is opened. + const nowMs = 3062 * 43_200_000 + 30 * DAY_MS + await saveTile(SITE, chunk()) // 5m, a month and a half old: expired + await saveTile( + SITE, + chunk({ + tileId: '1h/1/500', + res: '1h', + startMs: nowMs - 300 * DAY_MS, + stepMs: 3_600_000, + }) + ) + + const dropped = await pruneTiles(SITE, nowMs) + + expect(dropped).toBe(1) + const held = await loadTiles(SITE, ['5m/1/3061', '1h/1/500']) + expect(held.has('1h/1/500'), 'the year cache was judged by the month’s window').toBe(true) + expect(held.has('5m/1/3061')).toBe(false) + }) }) diff --git a/src/lib/store/tiles.ts b/src/lib/store/tiles.ts index d050416..ff0c8f6 100644 --- a/src/lib/store/tiles.ts +++ b/src/lib/store/tiles.ts @@ -17,7 +17,8 @@ import { db } from './db' import { cacheKey, seal, unseal, type Bytes } from './seal' -import type { HistChunk } from '$lib/protocol/messages' +import { RESOLUTIONS, type ResolutionSpec } from '$lib/protocol/history' +import type { HistChunk, Resolution } from '$lib/protocol/messages' export interface CachedTile { tileId: string @@ -115,8 +116,13 @@ export function haveList(cached: ReadonlyMap): { tileId: str * Without this the cache grows for as long as the app is installed, holding * years the box itself has already evicted. Cheap enough to run after a * query, which is the only time the answer changes. + * + * Every row is judged against its own resolution's retention. The store + * holds a month of 5m beside two years of 1h, so a single cutoff would let + * one answer's window evict the other resolution's whole cache — the year + * chart self-destructing every time the day chart is opened. */ -export async function pruneTiles(siteId: string, oldestKeptMs: number): Promise { +export async function pruneTiles(siteId: string, nowMs: number): Promise { try { const database = await db() const tx = database.transaction('tiles', 'readwrite') @@ -124,7 +130,11 @@ export async function pruneTiles(siteId: string, oldestKeptMs: number): Promise< for (const row of await tx.store.getAll()) { if (row.siteId !== siteId) continue - if (row.startMs >= oldestKeptMs) continue + // A resolution this build has never heard of has no retention to judge + // by, and data it cannot judge is data it must not delete. + const spec: ResolutionSpec | undefined = RESOLUTIONS[row.res as Resolution] + if (!spec) continue + if (row.startMs >= nowMs - spec.retentionMs) continue await tx.store.delete(row.key) dropped += 1 } diff --git a/src/lib/ui/Chart.svelte b/src/lib/ui/Chart.svelte index 0d5aaa7..a80ae2b 100644 --- a/src/lib/ui/Chart.svelte +++ b/src/lib/ui/Chart.svelte @@ -55,6 +55,16 @@ let { frame, traces, axis, ticks = [], cursor = null, onCursor, height = 200 }: Props = $props() + /** + * What a role that resolves to nothing is painted as. + * + * An empty resolution is a renamed token or a stylesheet that never + * mounted, and a theme-matched fallback would mask it in the theme it + * matches. One mid-grey for every role is visibly wrong in both themes, + * which keeps tokens.css the only place a real colour lives. + */ + const TOKEN_MISSING = '#808080' + let canvas = $state(null) let box = $state(null) let width = $state(320) @@ -121,12 +131,11 @@ // Resolved once per paint. Components read design roles, never raw // values, and getComputedStyle inside the stroke loop is a stall. const style = getComputedStyle(box) - const role = (name: string, fallback: string) => - style.getPropertyValue(name).trim() || fallback - const line = role('--line', '#2a2a2a') - const lineSoft = role('--line-soft', '#222222') - const sunken = role('--surface-sunken', '#101010') - const colors = traces.map((t) => role(t.colorVar, '#888')) + const role = (name: string) => style.getPropertyValue(name).trim() || TOKEN_MISSING + const line = role('--line') + const lineSoft = role('--line-soft') + const sunken = role('--surface-sunken') + const colors = traces.map((t) => role(t.colorVar)) // Hours the box has nothing for, shaded before anything is drawn over // them. Recessed rather than marked, so it reads as absence. @@ -164,7 +173,7 @@ shapes.forEach((shape, t) => { if (!shape) return - const color = colors[t] ?? '#888' + const color = colors[t] ?? TOKEN_MISSING if (shape.kind === 'band') { // The spread of readings inside each pixel, filled; their mean, drawn @@ -256,7 +265,7 @@ if (cursor !== null && cursor >= 0 && cursor < frame.points) { const cx = Math.round(x(cursor)) + 0.5 - ctx.strokeStyle = role('--fg-muted', '#858585') + ctx.strokeStyle = role('--fg-muted') ctx.lineWidth = 1 ctx.beginPath() ctx.moveTo(cx, 0) @@ -267,7 +276,7 @@ const column = frame.columns[frame.names.indexOf(trace.name)] const v = column?.[cursor!] if (v === undefined || v === MISSING_SAMPLE) return - ctx.fillStyle = colors[t] ?? '#888' + ctx.fillStyle = colors[t] ?? TOKEN_MISSING ctx.beginPath() ctx.arc(x(cursor!), y(v), 3, 0, Math.PI * 2) ctx.fill() diff --git a/src/lib/ui/Chart.svelte.test.ts b/src/lib/ui/Chart.svelte.test.ts index 42bbbf3..bda5b0e 100644 --- a/src/lib/ui/Chart.svelte.test.ts +++ b/src/lib/ui/Chart.svelte.test.ts @@ -109,11 +109,17 @@ function strokedPaths(ops: Op[]): Path[] { return out } -function mount(columns: Int32Array[], names: string[], traces: Trace[], axis: Domain) { +function mount( + columns: Int32Array[], + names: string[], + traces: Trace[], + axis: Domain, + roles: Record = ROLES +) { const { ops, ctx } = recorder() vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(ctx as never) vi.spyOn(window, 'getComputedStyle').mockReturnValue({ - getPropertyValue: (name: string) => ROLES[name] ?? '', + getPropertyValue: (name: string) => roles[name] ?? '', } as unknown as CSSStyleDeclaration) const frame: SeriesFrame = { @@ -214,4 +220,25 @@ describe('colour comes from the palette', () => { expect(Object.values(ROLES), `painted with ${colour}`).toContain(colour) } }) + + it('falls back to one neutral grey when a role resolves to nothing', () => { + // An empty resolution is a renamed token or a stylesheet that never + // mounted. The fallbacks used to be dark-palette hexes, which drew dark + // rules on a light card in light theme and blended into dark — masking + // the missing token in exactly one theme each. Wrong has to be visible + // the same way in both: one grey, everywhere. + const points = WIDTH * 2 + const grid = new Int32Array(points).fill(2000) + const pv = new Int32Array(points).fill(-1000) + + const ops = mount([grid, pv], ['grid_w', 'pv_w'], [GRID, PV], [-4000, 4000], {}) + const painted = new Set() + for (const op of ops) { + if (op.op === 'stroke') painted.add(op.strokeStyle) + if (op.op === 'fill' || op.op === 'fillRect') painted.add(op.fillStyle) + } + + expect(painted.size).toBeGreaterThan(0) + expect([...painted]).toEqual(['#808080']) + }) }) diff --git a/src/lib/ui/InstallHint.svelte b/src/lib/ui/InstallHint.svelte index 92ce1c5..976c659 100644 --- a/src/lib/ui/InstallHint.svelte +++ b/src/lib/ui/InstallHint.svelte @@ -29,7 +29,10 @@ Share, then Add to Home Screen. It opens instantly and keeps your readings between visits.

- + + {/if} diff --git a/src/lib/ui/InstallHint.svelte.test.ts b/src/lib/ui/InstallHint.svelte.test.ts new file mode 100644 index 0000000..ad32459 --- /dev/null +++ b/src/lib/ui/InstallHint.svelte.test.ts @@ -0,0 +1,36 @@ +/* The hint's one control, as voice control reaches it. + * + * Someone steering by voice says the word they can see. The button used to + * carry aria-label="Dismiss" over visible text saying "Close", so "tap + * Close" matched nothing — the accessible name and the printed one must be + * the same word. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest' +import { render, screen } from '@testing-library/svelte' + +// The hint decides once, from the real environment, whether to exist at +// all. What is under test is the button, not the iOS detection — so the +// gate is held open. +vi.mock('$lib/pwa/install', () => ({ + currentEnvironment: () => ({}), + hintAlreadySeen: () => false, + isIosSafariTab: () => true, + markHintSeen: () => {}, +})) + +import InstallHint from './InstallHint.svelte' + +describe('the install hint', () => { + afterEach(() => { + document.body.replaceChildren() + vi.restoreAllMocks() + }) + + it('names its button by the word printed on it', async () => { + render(InstallHint) + + const button = await screen.findByRole('button', { name: 'Close' }) + expect(button.getAttribute('aria-label'), 'an aria-label overrode the visible word').toBeNull() + }) +}) diff --git a/src/views/Access.svelte b/src/views/Access.svelte index c09b2f4..4f70c01 100644 --- a/src/views/Access.svelte +++ b/src/views/Access.svelte @@ -161,6 +161,10 @@ {#await import('$lib/ui/QrCode.svelte') then module} {@const QrCode = module.default} + {:catch} + +

The code didn't load — open this screen again to draw it.

{/await} {:else}

diff --git a/src/views/Box.svelte b/src/views/Box.svelte index a166b08..97d33dc 100644 --- a/src/views/Box.svelte +++ b/src/views/Box.svelte @@ -46,12 +46,25 @@ * Rejects with the home still on the phone and still working. */ leave: () => Promise + /** + * The shell's word that the last sign-out attempt failed. + * + * A failed leave tears every view down and puts the home back, this + * screen included, so the failure cannot live in this screen's own + * state — the instance that met it is gone. Opening on the same + * sentence it would have shown beats a Sign out button pretending + * nothing happened. + */ + stuck?: boolean } - let { site, leave }: Props = $props() + let { site, leave, stuck = false }: Props = $props() type Stage = 'idle' | 'confirming' | 'leaving' | 'stuck' | 'copy-kept' - let stage = $state('idle') + // Read once at construction, on purpose: the shell's word is about how + // this instance opens, not a value to follow afterwards. + // svelte-ignore state_referenced_locally + let stage = $state(stuck ? 'stuck' : 'idle') /** * Whether Sourceful is holding a sealed copy of this home. diff --git a/src/views/Box.svelte.test.ts b/src/views/Box.svelte.test.ts index 8e8599f..fa671a4 100644 --- a/src/views/Box.svelte.test.ts +++ b/src/views/Box.svelte.test.ts @@ -186,6 +186,21 @@ describe('leaving, from the phone', () => { const stillThere = await database.get('sites', SITE_ID) expect(stillThere, 'the home this screen says is still here').toBeTruthy() }) + + it('opens on the unfinished sign-out when the shell says so', async () => { + // A failed leave tears this screen down with every other view when the + // shell puts the home back, so the instance that met the failure — and + // held it in local state — is gone. The shell's word is what keeps the + // sentence on screen instead of a Sign out button pretending nothing + // happened. + const site = new SiteStore('test') + void site.start(SITE_ID) + render(Box, { props: { site, leave: vi.fn(async () => {}), stuck: true } }) + + const said = () => (document.body.textContent ?? '').replace(/\s+/g, ' ') + await vi.waitFor(() => expect(said()).toMatch(/That didn.t finish/i)) + expect(said()).toMatch(/still on this phone and still works/i) + }) }) /* The spare key, and what it costs. diff --git a/src/views/Energy.svelte b/src/views/Energy.svelte index 8ea4706..651858d 100644 --- a/src/views/Energy.svelte +++ b/src/views/Energy.svelte @@ -18,7 +18,7 @@ crash — the same rule as every other capability. --> -{#if !hasHome} -

-

Nothing paired yet

-

- Scan the code shown on your FTW box to connect. Everything stays between - this app and your box — nothing readable passes through Sourceful. -

- -

Pairing lands with the enrollment flow.

-
-{:else if site.session.phase === 'booting'} +{#if site.session.phase === 'booting'}

Your box is starting

@@ -78,7 +99,7 @@ Nothing is wrong — it will appear here as soon as it is ready.

{#if site.session.boot} -

{site.session.boot.phase} · {site.session.boot.pct}%

+

{bootWords(site.session.boot.phase)} · {site.session.boot.pct}%

{/if}
{:else if site.session.phase === 'terminated'} @@ -218,11 +239,6 @@ font-weight: 500; } - .primary:disabled { - opacity: 0.4; - cursor: default; - } - /* The same pair of weights the pairing screen uses, because they lead to the same screen: the primary is for a phone that cannot get in at all, and the quiet one is a question asked while the app is still trying. */ diff --git a/src/views/Now.svelte.test.ts b/src/views/Now.svelte.test.ts new file mode 100644 index 0000000..607eb5b --- /dev/null +++ b/src/views/Now.svelte.test.ts @@ -0,0 +1,118 @@ +/* The first screen, on the two honesty switches it owns. + * + * The house diagram claims two things a glance cannot check: that moving + * particles mean power is flowing at this very moment, and that the sentence + * on the boot screen is a sentence. Both fail silently — a quiet inverter + * keeps the particles moving over readings a minute old, and a wire enum + * reads as a fault code on the one screen that promises nothing is wrong. + * + * Everything is real: a Session, a SimBox, the loopback carrier, the box's + * own . Only the feed call is a spy. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest' +import { render } from '@testing-library/svelte' +import Now from './Now.svelte' +import { SiteStore } from '$lib/state/site.svelte' +import { LoopbackCarrier } from '$lib/carrier/loopback' +import { SimBox } from '$lib/sim/box' +import type { FtwEnergyFlowElement } from '$vendor/ftw/ftw-energy-flow.js' + +/** Fixed so the simulated house is the same every run. */ +const NOON = new Date(2026, 6, 15, 12, 0, 0).getTime() + +function flowEl(): FtwEnergyFlowElement | null { + return document.querySelector('ftw-energy-flow') +} + +describe('the Now screen', () => { + afterEach(() => { + document.body.replaceChildren() + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('speaks about a starting box in words, never the wire token', async () => { + // The box sends codes; this app owns all prose. boot.phase is a name + // shared with the box — 'vacuum' is a database being compacted, and on + // the screen that says nothing is wrong it reads as a fault. + vi.useFakeTimers() + vi.setSystemTime(NOON) + + const box = new SimBox({ now: () => Date.now(), faults: { booting: true } }) + const site = new SiteStore('test') + site.connect(new LoopbackCarrier(box, { latencyMs: 0 })) + + render(Now, { props: { site, active: true } }) + const text = () => document.body.textContent ?? '' + for (let i = 0; i < 100 && !/starting/i.test(text()); i++) { + await vi.advanceTimersByTimeAsync(20) + } + + expect(text()).toMatch(/Your box is starting/i) + expect(text(), 'a wire token reached the screen').not.toMatch(/\bvacuum\b|\bmigrate\b|\bdrivers\b/i) + // The progress itself is kept — it is the one number worth showing. + expect(text()).toMatch(/40\s?%/) + }) + + it('holds the house still when a source goes quiet on a healthy socket', async () => { + // The case the static switch exists for and phase alone cannot see: + // connected, streaming, and the inverter went quiet a while ago. Moving + // particles over those readings claim power is flowing right now. + vi.useFakeTimers() + vi.setSystemTime(NOON) + + const box = new SimBox({ now: () => Date.now() }) + const site = new SiteStore('test') + site.connect(new LoopbackCarrier(box, { latencyMs: 0 })) + + render(Now, { props: { site, active: true } }) + for (let i = 0; i < 100 && !flowEl(); i++) await vi.advanceTimersByTimeAsync(20) + + expect(flowEl(), 'the house never drew at all').not.toBeNull() + expect(flowEl()!.hasAttribute('static'), 'a live stream was drawn still').toBe(false) + + box.faults = { ...box.faults, sourceStates: { 'inverter.sungrow': 'stale' } } + box.tick(1_000) + await vi.advanceTimersByTimeAsync(100) + + expect(site.session.phase, 'the session moved, so this is not the case under test').toBe( + 'streaming' + ) + expect(site.srcState).not.toBe('live') + expect( + flowEl()!.hasAttribute('static'), + 'particles kept flowing over readings that are not current' + ).toBe(true) + }) + + it('does not feed the diagram while hidden, and catches up on return', async () => { + // Now stays mounted behind the other tabs, hidden by the shell. The + // stream keeps arriving either way; pushing it into a display:none SVG + // at 1 Hz is work nobody can see. Coming back must start from the + // present, not replay what was skipped. + vi.useFakeTimers() + vi.setSystemTime(NOON) + + const box = new SimBox({ now: () => Date.now() }) + const site = new SiteStore('test') + site.connect(new LoopbackCarrier(box, { latencyMs: 0 })) + + const { rerender } = render(Now, { props: { site, active: false } }) + for (let i = 0; i < 100 && !flowEl(); i++) await vi.advanceTimersByTimeAsync(20) + expect(flowEl()).not.toBeNull() + + const fed = vi.spyOn(flowEl()!, 'setReadings') + + // Ten seconds of live power into a view another tab is covering. + for (let i = 0; i < 10; i++) { + box.tick() + await vi.advanceTimersByTimeAsync(1_000) + } + expect(fed, 'a hidden SVG was fed at 1 Hz').not.toHaveBeenCalled() + + await rerender({ active: true }) + await vi.advanceTimersByTimeAsync(20) + expect(fed, 'coming back never caught the view up').toHaveBeenCalled() + }) +}) diff --git a/src/views/Pair.svelte b/src/views/Pair.svelte index 5fe86c2..17e72c3 100644 --- a/src/views/Pair.svelte +++ b/src/views/Pair.svelte @@ -9,7 +9,7 @@