Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
57 changes: 53 additions & 4 deletions src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
*
Expand All @@ -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')
Expand All @@ -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
}

Expand Down Expand Up @@ -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. -->
<div class="view" hidden={router.current !== 'now'}>
<Now {site} {hasHome} {connectHelp} wayBack={() => (recovering = true)} />
<Now
{site}
active={router.current === 'now'}
{connectHelp}
wayBack={() => (recovering = true)}
/>
</div>

{#if seen.plan}
Expand All @@ -388,6 +417,14 @@
{#await import('$views/History.svelte') then module}
{@const History = module.default}
<History {site} />
{:catch}
<!-- The chunk never arrived — an update mid-flight, a network that
died under it. Nothing retries a failed import; the next
launch fetches it fresh, and silence here reads as a broken
tab rather than a lost download. -->
<p class="load-note">
This screen didn't load — it will try again next time you open the app.
</p>
{/await}
</div>
{/if}
Expand All @@ -398,7 +435,11 @@
<div class="view" hidden={router.current !== 'box'}>
{#await import('$views/Box.svelte') then module}
{@const Box = module.default}
<Box {site} {leave} />
<Box {site} {leave} stuck={leaveFailed} />
{:catch}
<p class="load-note">
This screen didn't load — it will try again next time you open the app.
</p>
{/await}
</div>
{/if}
Expand Down Expand Up @@ -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. */
Expand Down
52 changes: 52 additions & 0 deletions src/App.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
Expand Down
13 changes: 0 additions & 13 deletions src/lib/carrier/carrier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
42 changes: 40 additions & 2 deletions src/lib/carrier/handshake-resilience.test.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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])])
})
})
44 changes: 43 additions & 1 deletion src/lib/carrier/noise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<typeof setTimeout> | undefined
#retry: ReturnType<typeof setTimeout> | undefined
#attempt = 0
#log: ((line: string) => void) | undefined

/** Kept so each reconnection can start a fresh handshake from the same input. */
Expand Down Expand Up @@ -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 = []

Expand All @@ -145,17 +162,22 @@ export class NoiseCarrier extends CarrierBase implements Carrier {
if (this.#closed) return

if (s.phase === 'open') {
this.#attempt = 0
this.#clearRetry()
this.#beginHandshake()
return
}

// 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)
}

Expand Down Expand Up @@ -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) => {
Expand All @@ -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
}
}
Loading