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
77 changes: 77 additions & 0 deletions web/src/fleet/fleet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1678,6 +1678,31 @@ describe('a loopback tab that never ran a ceremony', () => {
h.fleet.close()
})

it('forgets the fleet when the welcome says this machine is not on a relay', async () => {
// `flue relay leave`, or a relay.json removed by hand, under a tab that was
// already open: the socket comes back and the daemon's welcome names no
// relay at all. What the last expansion could not build is not a fact about
// anything any more — there is no fleet for this browser to be missing
// from — so the snapshot the sessions screen speaks from goes with it.
// Without this it outlived the fleet it described, and the band went on
// telling the reader about machines that no longer exist to be reached.
const post = enrolFetch('this machine holds no fleet key', { ok: false, status: 409 })
const directory = directoryFetch([])
const h = loopbackFleet(() => enrolThisBrowser(post), directory)
h.fleet.connect()
h.local.open()
h.local.emitWelcome(loopbackWelcome())
await vi.waitFor(() => expect(h.fleet.gaps()).not.toBeNull())

// `status: 'off'` rather than an absent relay, which is the spelling the
// protocol says never arrives and every consumer must read the same way
// (client/protocol.ts, RelayInfo).
h.local.emitWelcome(welcome({ status: 'off' }))

expect(h.fleet.gaps()).toBeNull()
h.fleet.close()
})

it('keeps the machine it is on when the relay cannot be read', async () => {
// 502 from the daemon's proxy: the fault is upstream of this machine — the
// relay is down, or the leg is mid-dial — and the tab is told so rather
Expand Down Expand Up @@ -1888,4 +1913,56 @@ describe('discovery', () => {
expect(expand).not.toHaveBeenCalled()
h.fleet.close()
})

it('stops reading once the machine has left the relay it named', async () => {
// The origin was learned once and kept for the epoch, so a tab open across
// a `flue relay leave` went on asking a relay this machine had left, every
// minute and on every focus, for as long as it stayed open. A welcome that
// names no relay is this machine saying it has none; the origin it named
// before is spent.
vi.useFakeTimers()
const expand = vi.fn(() => Promise.resolve([] as FleetSource[]))
const h = harness([[LOCAL_MACHINE_ID, '']], expand)
h.fleet.connect()
h.fake(LOCAL_MACHINE_ID).open()
h.fake(LOCAL_MACHINE_ID).emitWelcome(
welcome({ status: 'connected', origin: 'https://relay.example' }),
)
await vi.advanceTimersByTimeAsync(0)
expect(expand).toHaveBeenCalledTimes(1)

h.fake(LOCAL_MACHINE_ID).emitWelcome(welcome())

await vi.advanceTimersByTimeAsync(180_000)
window.dispatchEvent(new Event('focus'))
await vi.advanceTimersByTimeAsync(0)
expect(expand).toHaveBeenCalledTimes(1)
h.fleet.close()
})

it('expands again for a machine that joins a relay a second time', async () => {
// The other half of forgetting: a machine that left and joined again — or
// was repointed at a new relay — hands its tab a fresh origin, and the tab
// has to build against it rather than sit on the once-per-epoch flag the
// first expansion set.
vi.useFakeTimers()
const expand = vi.fn(() => Promise.resolve([] as FleetSource[]))
const h = harness([[LOCAL_MACHINE_ID, '']], expand)
h.fleet.connect()
h.fake(LOCAL_MACHINE_ID).open()
h.fake(LOCAL_MACHINE_ID).emitWelcome(
welcome({ status: 'connected', origin: 'https://relay.example' }),
)
await vi.advanceTimersByTimeAsync(0)
h.fake(LOCAL_MACHINE_ID).emitWelcome(welcome())

h.fake(LOCAL_MACHINE_ID).emitWelcome(
welcome({ status: 'connected', origin: 'https://second.example' }),
)
await vi.advanceTimersByTimeAsync(0)

expect(expand).toHaveBeenCalledTimes(2)
expect(expand).toHaveBeenLastCalledWith('https://second.example')
h.fleet.close()
})
})
95 changes: 83 additions & 12 deletions web/src/fleet/fleet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,11 @@ export interface FleetSource {
* A snapshot of one expansion, not a verdict. The fleet re-expands the moment a
* welcome hands over something that changes the answer, so a gap reported at
* boot may be gone a heartbeat later; see FleetClient.adoptFromWelcome.
*
* **There is one of these only while this tab knows a relay**, and that is the
* precondition every sentence a screen writes from it stands on: gaps are a
* statement about a fleet, and a machine with no relay is not on one. See
* FleetClient.gaps.
*/
export interface FleetGaps {
/**
Expand Down Expand Up @@ -364,9 +369,24 @@ export class FleetClient {
}

/**
* What this browser could not reach, as of the last expansion, or null on a
* tab that has not run one — a loopback tab before its welcome names a
* relay, or a test driving scripted sources.
* What this browser could not reach on the fleet it is on, as of the last
* expansion — and null whenever there is no fleet for that to be a fact
* about.
*
* Null covers three states, and they are one state as far as any screen is
* concerned: a tab that has run no expansion (a loopback tab before its
* welcome names a relay, or a test driving scripted sources), a machine that
* has no relay configured at all, and a machine that had one and has left it.
* A tab in any of them reaches exactly the machines it can reach, and has no
* absent fleet to apologise for.
*
* That last one is why this is worth stating rather than leaving implicit. A
* gaps snapshot used to outlive the relay that gave it meaning: a tab open
* across a `flue relay leave` kept the last expansion's counts for the life
* of the tab, and the sessions screen kept telling its reader to go and
* repair a fleet that no longer existed. `localWelcome` drops it when the
* daemon says it is not on a relay, and `noteGaps` refuses to take a new one
* while that is true.
*
* Read after an onFleet delivery: noteGaps emits, so a screen holding this
* as state is told when it changes for the same reason it is told when a
Expand All @@ -378,6 +398,10 @@ export class FleetClient {

/** Record what the builder skipped, and tell the screens if it changed. */
private noteGaps(g: FleetGaps) {
// Nothing to be missing from. An expansion still in flight when the daemon
// said it had left the relay would otherwise land its counts here after
// localWelcome had dropped them, and put the band back up.
if (this.relayOrigin === null) return
const held = this.gapsState
if (
held !== null &&
Expand Down Expand Up @@ -659,6 +683,9 @@ export class FleetClient {
* here, it triggers the one deferred source construction — once per
* epoch, because the welcome that carries it will arrive again on every
* reconnect and the machines it names are already held.
*
* And one fact that arrives by *not* being there: a welcome with no relay on
* it is this machine saying it is on none. See `forgetRelay`.
*/
private localWelcome(w: Welcome) {
const slot = this.slots.find((s) => s.id === LOCAL_MACHINE_ID)
Expand All @@ -681,21 +708,65 @@ export class FleetClient {
}
}

const origin = w.relay?.origin
if (origin !== undefined) {
// Remembered whether or not it triggers a build, because the *other*
// trigger — a certificate arriving after the expansion already ran —
// has no welcome of its own to read an origin from.
this.relayOrigin = origin
if (!this.expanded) {
this.expanded = true
void this.adoptRemotes(origin)
// Absent and `off` are the same statement, and the protocol says so in as
// many words: a daemon with no relay omits the field, and `off` is the
// spelling that never arrives (client/protocol.ts, RelayInfo). A relay that
// is merely `connecting` is a relay — configured, dialling, and about to
// name an origin — so it is not this.
if (w.relay === undefined || w.relay.status === 'off') {
if (this.forgetRelay()) changed = true
} else {
const origin = w.relay.origin
if (origin !== undefined) {
// Remembered whether or not it triggers a build, because the *other*
// trigger — a certificate arriving after the expansion already ran —
// has no welcome of its own to read an origin from.
this.relayOrigin = origin
if (!this.expanded) {
this.expanded = true
void this.adoptRemotes(origin)
}
}
}

if (changed) this.emit()
}

/**
* Take this tab off the relay it was told about, because the machine it
* rides has just said it is on none.
*
* The state this exists for is a tab left open across a `flue relay leave`,
* or a relay.json deleted by hand and the daemon restarted under it: the
* socket comes back, the welcome names no relay, and everything this tab
* learned from the last one is spent. Two things were wrong without it, and
* both lasted for the life of the tab —
*
* - the discovery tick kept reading a directory through a machine that had
* left the relay, once a minute and on every focus, for an answer that
* can only be a 404 from its own daemon; and
* - the gaps snapshot outlived the fleet it described, so the sessions
* screen went on telling the reader to repair a fleet that no longer
* exists — advice that is not merely stale but impossible to act on.
*
* `expanded` goes back down with it so a machine that joins a relay again
* builds against the new origin rather than sitting on a flag the first
* expansion set. The slots already built stay exactly where they are: this
* machine leaving a relay says nothing about whether the *other* machines are
* still on it, and dropping them would be the sessions screen emptying itself
* on a fact about somebody else — the same reason `adoptRemotes` is additive.
*
* Reports whether anything changed, so the ordinary welcome — every reconnect
* on a machine that never had a relay — costs one comparison and no emit.
*/
private forgetRelay(): boolean {
if (this.relayOrigin === null && this.gapsState === null) return false
this.relayOrigin = null
this.expanded = false
this.gapsState = null
return true
}

/**
* A welcome's fleet key and certificate, kept — and the machines they unlock,
* taken now rather than on the next page load.
Expand Down
56 changes: 56 additions & 0 deletions web/src/routes/sessions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,62 @@ describe('SessionsRoute', () => {
expect(screen.queryByText(/pair there/)).toBeNull()
})

it('says nothing at all about a fleet on a machine with no relay', async () => {
// The state a fresh install is in, and the first screen anybody sees:
// one machine, no relay.json, no fleet. Every sentence the band has is
// false here — there are no others to be missing, and nothing to pair
// against — so the honest band is no band. Setting up remote access is
// the Remote screen's offer to make, not this one's.
vi.stubGlobal('indexedDB', new IDBFactory())
localStorage.clear()
const { sock } = await mountSessions({ solo: true })
listed(sock, [info({ id: 's1' })])

act(() =>
sock.emitControl({ type: 'welcome', daemonId: 'd1', host: 'mesa.local', ver: '0.1.0' }),
)
// Long enough for an expansion to have run and reported, had this tab had
// anything to expand into.
await act(async () => {
await new Promise((r) => setTimeout(r, 0))
})

expect(screen.queryByText(/no key for the fleet/)).toBeNull()
expect(screen.queryByText(/this fleet/)).toBeNull()
// And the machine itself is on screen, working, which is the whole of
// what that state is.
expect(screen.getByRole('link', { name: 'Open zsh' })).toBeTruthy()
})

it('takes the band down when its machine leaves the relay', async () => {
// The way the wrong advice actually reached a reader: the band is right
// while the machine is on a relay, and the machine then leaves — `flue
// relay leave`, or a relay.json deleted by hand — and the daemon comes
// back naming no relay. The snapshot the band spoke from used to outlive
// the fleet it described, so a tab left open kept telling its reader to
// go and repair a fleet that no longer existed.
vi.stubGlobal('indexedDB', new IDBFactory())
localStorage.clear()
const { sock } = await mountSessions()
listed(sock, [info({ id: 's1' })])
act(() =>
sock.emitControl({
type: 'welcome',
daemonId: 'd1',
host: 'mesa.local',
ver: '0.1.0',
relay: { status: 'connected', origin: 'https://relay.example' },
}),
)
await waitFor(() => expect(screen.getByText(/holds\s+no key for the fleet/)).toBeTruthy())

act(() =>
sock.emitControl({ type: 'welcome', daemonId: 'd1', host: 'mesa.local', ver: '0.1.0' }),
)

await waitFor(() => expect(screen.queryByText(/no key for the fleet/)).toBeNull())
})

it('reports a lost local daemon rather than showing an empty screen', async () => {
const { sock } = await mountSessions()
listed(sock, [info({ id: 's1' })])
Expand Down
13 changes: 12 additions & 1 deletion web/src/routes/sessions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,9 @@ export function SessionsRoute() {
/**
* What the fleet could not build for this browser: machines it has no
* certificate for, and whether it pinned a fleet key at all. Null until an
* expansion has run, which is every tab before its welcome names a relay.
* expansion has run, which is every tab before its welcome names a relay —
* and null again the moment its machine says it is on none, because there is
* then no fleet for any of it to be a fact about (FleetClient.gaps).
*
* Read off the fleet on each delivery rather than carried in the payload,
* because it changes once per expansion and the payload is delivered several
Expand Down Expand Up @@ -868,6 +870,15 @@ function PlacedBulkBar(props: {
* Silent when there is nothing to say, which is now the ordinary case
* everywhere — including on loopback, where enrolment is what closes the last
* two gaps. A band reading "everything is fine" is a band nobody reads.
*
* And silent before any of that on a machine with no relay, which is what a
* fresh install is and the first screen anybody ever opens. Every sentence
* below is addressed to a reader who is on a fleet: there are no others to be
* missing from a list, and nothing anywhere to pair against, so all three would
* be false at once. The fleet is what makes that hold rather than a fourth
* branch here — it hands over no gaps at all while this tab knows no relay
* (fleet/fleet.ts, FleetClient.gaps) — and the invitation to set up remote
* access is the Remote screen's to make, on the screen that can carry it out.
*/
function FleetGapBand({ gaps }: { gaps: FleetGaps }) {
if (!gaps.fleetKey && gaps.pinned === 0) {
Expand Down