Skip to content

Commit 97cb210

Browse files
karngyanclaude
andauthored
fix(web): no fleet, no fleet band (#49)
The sessions screen's gap band tells a reader their browser holds no key for the fleet and that pairing again from any machine on it will fix that. On a machine with no relay every clause of that is false: there is no fleet, no others to be missing, and no machine to pair against — and it is advice that cannot be followed and would fix nothing if it were. The band is fed by FleetGaps, which only an expansion produces, so it could only ever appear on a tab that had learned a relay origin. What it never did was give that origin back. A tab open across a `flue relay leave`, or a relay.json deleted by hand and the daemon restarted under it, reconnects to a welcome naming no relay at all — and kept the last expansion's counts for the life of the tab, plus a discovery tick asking a directory through a machine that had left, once a minute and on every focus, for an answer that can only be its own daemon's 404. So the fleet reads the fact it was already sent and ignored: a welcome with no relay on it — absent, or `off`, which the protocol says are one statement — is this machine saying it is on none. The origin goes, the once-per-epoch expansion flag goes with it so a machine that joins again builds against the new origin, and the gaps snapshot goes because it describes a fleet that is not there. noteGaps refuses a late one for the same reason, so an expansion still in flight cannot put the band back up. The slots already built stay: this machine leaving a relay says nothing about whether the others are still on it, which is the same reason adoptRemotes is additive. What each state says now. No relay, this load or since: nothing — one machine and no relay is a whole state, and the invitation to set up remote access belongs to the Remote screen, which can carry it out. A relay and no fleet key with no ceremony to this browser's name: the enrolment story from #46, unchanged — enrolThisBrowser answers a bare boolean, so 409 (this machine holds no fleet key) and 502 (the relay would not answer) cannot be told apart here, and `check flue status on this machine` is the honest sentence for both. A relay, no fleet key, and a machine this browser paired with: that machine is about to hand one over, unchanged. A relay and machines with no certificate to present to: unchanged. Tests: two at the fleet, two at the screen. The screen's `takes the band down when its machine leaves the relay` is the one that reproduces the report — it fails on main with the band still up, its text quoted in the diff — and `stops reading once the machine has left the relay it named` fails at four directory reads where it wants one. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 759890d commit 97cb210

4 files changed

Lines changed: 228 additions & 13 deletions

File tree

web/src/fleet/fleet.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1678,6 +1678,31 @@ describe('a loopback tab that never ran a ceremony', () => {
16781678
h.fleet.close()
16791679
})
16801680

1681+
it('forgets the fleet when the welcome says this machine is not on a relay', async () => {
1682+
// `flue relay leave`, or a relay.json removed by hand, under a tab that was
1683+
// already open: the socket comes back and the daemon's welcome names no
1684+
// relay at all. What the last expansion could not build is not a fact about
1685+
// anything any more — there is no fleet for this browser to be missing
1686+
// from — so the snapshot the sessions screen speaks from goes with it.
1687+
// Without this it outlived the fleet it described, and the band went on
1688+
// telling the reader about machines that no longer exist to be reached.
1689+
const post = enrolFetch('this machine holds no fleet key', { ok: false, status: 409 })
1690+
const directory = directoryFetch([])
1691+
const h = loopbackFleet(() => enrolThisBrowser(post), directory)
1692+
h.fleet.connect()
1693+
h.local.open()
1694+
h.local.emitWelcome(loopbackWelcome())
1695+
await vi.waitFor(() => expect(h.fleet.gaps()).not.toBeNull())
1696+
1697+
// `status: 'off'` rather than an absent relay, which is the spelling the
1698+
// protocol says never arrives and every consumer must read the same way
1699+
// (client/protocol.ts, RelayInfo).
1700+
h.local.emitWelcome(welcome({ status: 'off' }))
1701+
1702+
expect(h.fleet.gaps()).toBeNull()
1703+
h.fleet.close()
1704+
})
1705+
16811706
it('keeps the machine it is on when the relay cannot be read', async () => {
16821707
// 502 from the daemon's proxy: the fault is upstream of this machine — the
16831708
// relay is down, or the leg is mid-dial — and the tab is told so rather
@@ -1888,4 +1913,56 @@ describe('discovery', () => {
18881913
expect(expand).not.toHaveBeenCalled()
18891914
h.fleet.close()
18901915
})
1916+
1917+
it('stops reading once the machine has left the relay it named', async () => {
1918+
// The origin was learned once and kept for the epoch, so a tab open across
1919+
// a `flue relay leave` went on asking a relay this machine had left, every
1920+
// minute and on every focus, for as long as it stayed open. A welcome that
1921+
// names no relay is this machine saying it has none; the origin it named
1922+
// before is spent.
1923+
vi.useFakeTimers()
1924+
const expand = vi.fn(() => Promise.resolve([] as FleetSource[]))
1925+
const h = harness([[LOCAL_MACHINE_ID, '']], expand)
1926+
h.fleet.connect()
1927+
h.fake(LOCAL_MACHINE_ID).open()
1928+
h.fake(LOCAL_MACHINE_ID).emitWelcome(
1929+
welcome({ status: 'connected', origin: 'https://relay.example' }),
1930+
)
1931+
await vi.advanceTimersByTimeAsync(0)
1932+
expect(expand).toHaveBeenCalledTimes(1)
1933+
1934+
h.fake(LOCAL_MACHINE_ID).emitWelcome(welcome())
1935+
1936+
await vi.advanceTimersByTimeAsync(180_000)
1937+
window.dispatchEvent(new Event('focus'))
1938+
await vi.advanceTimersByTimeAsync(0)
1939+
expect(expand).toHaveBeenCalledTimes(1)
1940+
h.fleet.close()
1941+
})
1942+
1943+
it('expands again for a machine that joins a relay a second time', async () => {
1944+
// The other half of forgetting: a machine that left and joined again — or
1945+
// was repointed at a new relay — hands its tab a fresh origin, and the tab
1946+
// has to build against it rather than sit on the once-per-epoch flag the
1947+
// first expansion set.
1948+
vi.useFakeTimers()
1949+
const expand = vi.fn(() => Promise.resolve([] as FleetSource[]))
1950+
const h = harness([[LOCAL_MACHINE_ID, '']], expand)
1951+
h.fleet.connect()
1952+
h.fake(LOCAL_MACHINE_ID).open()
1953+
h.fake(LOCAL_MACHINE_ID).emitWelcome(
1954+
welcome({ status: 'connected', origin: 'https://relay.example' }),
1955+
)
1956+
await vi.advanceTimersByTimeAsync(0)
1957+
h.fake(LOCAL_MACHINE_ID).emitWelcome(welcome())
1958+
1959+
h.fake(LOCAL_MACHINE_ID).emitWelcome(
1960+
welcome({ status: 'connected', origin: 'https://second.example' }),
1961+
)
1962+
await vi.advanceTimersByTimeAsync(0)
1963+
1964+
expect(expand).toHaveBeenCalledTimes(2)
1965+
expect(expand).toHaveBeenLastCalledWith('https://second.example')
1966+
h.fleet.close()
1967+
})
18911968
})

web/src/fleet/fleet.ts

Lines changed: 83 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,11 @@ export interface FleetSource {
147147
* A snapshot of one expansion, not a verdict. The fleet re-expands the moment a
148148
* welcome hands over something that changes the answer, so a gap reported at
149149
* boot may be gone a heartbeat later; see FleetClient.adoptFromWelcome.
150+
*
151+
* **There is one of these only while this tab knows a relay**, and that is the
152+
* precondition every sentence a screen writes from it stands on: gaps are a
153+
* statement about a fleet, and a machine with no relay is not on one. See
154+
* FleetClient.gaps.
150155
*/
151156
export interface FleetGaps {
152157
/**
@@ -364,9 +369,24 @@ export class FleetClient {
364369
}
365370

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

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

684-
const origin = w.relay?.origin
685-
if (origin !== undefined) {
686-
// Remembered whether or not it triggers a build, because the *other*
687-
// trigger — a certificate arriving after the expansion already ran —
688-
// has no welcome of its own to read an origin from.
689-
this.relayOrigin = origin
690-
if (!this.expanded) {
691-
this.expanded = true
692-
void this.adoptRemotes(origin)
711+
// Absent and `off` are the same statement, and the protocol says so in as
712+
// many words: a daemon with no relay omits the field, and `off` is the
713+
// spelling that never arrives (client/protocol.ts, RelayInfo). A relay that
714+
// is merely `connecting` is a relay — configured, dialling, and about to
715+
// name an origin — so it is not this.
716+
if (w.relay === undefined || w.relay.status === 'off') {
717+
if (this.forgetRelay()) changed = true
718+
} else {
719+
const origin = w.relay.origin
720+
if (origin !== undefined) {
721+
// Remembered whether or not it triggers a build, because the *other*
722+
// trigger — a certificate arriving after the expansion already ran —
723+
// has no welcome of its own to read an origin from.
724+
this.relayOrigin = origin
725+
if (!this.expanded) {
726+
this.expanded = true
727+
void this.adoptRemotes(origin)
728+
}
693729
}
694730
}
695731

696732
if (changed) this.emit()
697733
}
698734

735+
/**
736+
* Take this tab off the relay it was told about, because the machine it
737+
* rides has just said it is on none.
738+
*
739+
* The state this exists for is a tab left open across a `flue relay leave`,
740+
* or a relay.json deleted by hand and the daemon restarted under it: the
741+
* socket comes back, the welcome names no relay, and everything this tab
742+
* learned from the last one is spent. Two things were wrong without it, and
743+
* both lasted for the life of the tab —
744+
*
745+
* - the discovery tick kept reading a directory through a machine that had
746+
* left the relay, once a minute and on every focus, for an answer that
747+
* can only be a 404 from its own daemon; and
748+
* - the gaps snapshot outlived the fleet it described, so the sessions
749+
* screen went on telling the reader to repair a fleet that no longer
750+
* exists — advice that is not merely stale but impossible to act on.
751+
*
752+
* `expanded` goes back down with it so a machine that joins a relay again
753+
* builds against the new origin rather than sitting on a flag the first
754+
* expansion set. The slots already built stay exactly where they are: this
755+
* machine leaving a relay says nothing about whether the *other* machines are
756+
* still on it, and dropping them would be the sessions screen emptying itself
757+
* on a fact about somebody else — the same reason `adoptRemotes` is additive.
758+
*
759+
* Reports whether anything changed, so the ordinary welcome — every reconnect
760+
* on a machine that never had a relay — costs one comparison and no emit.
761+
*/
762+
private forgetRelay(): boolean {
763+
if (this.relayOrigin === null && this.gapsState === null) return false
764+
this.relayOrigin = null
765+
this.expanded = false
766+
this.gapsState = null
767+
return true
768+
}
769+
699770
/**
700771
* A welcome's fleet key and certificate, kept — and the machines they unlock,
701772
* taken now rather than on the next page load.

web/src/routes/sessions.test.tsx

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,62 @@ describe('SessionsRoute', () => {
561561
expect(screen.queryByText(/pair there/)).toBeNull()
562562
})
563563

564+
it('says nothing at all about a fleet on a machine with no relay', async () => {
565+
// The state a fresh install is in, and the first screen anybody sees:
566+
// one machine, no relay.json, no fleet. Every sentence the band has is
567+
// false here — there are no others to be missing, and nothing to pair
568+
// against — so the honest band is no band. Setting up remote access is
569+
// the Remote screen's offer to make, not this one's.
570+
vi.stubGlobal('indexedDB', new IDBFactory())
571+
localStorage.clear()
572+
const { sock } = await mountSessions({ solo: true })
573+
listed(sock, [info({ id: 's1' })])
574+
575+
act(() =>
576+
sock.emitControl({ type: 'welcome', daemonId: 'd1', host: 'mesa.local', ver: '0.1.0' }),
577+
)
578+
// Long enough for an expansion to have run and reported, had this tab had
579+
// anything to expand into.
580+
await act(async () => {
581+
await new Promise((r) => setTimeout(r, 0))
582+
})
583+
584+
expect(screen.queryByText(/no key for the fleet/)).toBeNull()
585+
expect(screen.queryByText(/this fleet/)).toBeNull()
586+
// And the machine itself is on screen, working, which is the whole of
587+
// what that state is.
588+
expect(screen.getByRole('link', { name: 'Open zsh' })).toBeTruthy()
589+
})
590+
591+
it('takes the band down when its machine leaves the relay', async () => {
592+
// The way the wrong advice actually reached a reader: the band is right
593+
// while the machine is on a relay, and the machine then leaves — `flue
594+
// relay leave`, or a relay.json deleted by hand — and the daemon comes
595+
// back naming no relay. The snapshot the band spoke from used to outlive
596+
// the fleet it described, so a tab left open kept telling its reader to
597+
// go and repair a fleet that no longer existed.
598+
vi.stubGlobal('indexedDB', new IDBFactory())
599+
localStorage.clear()
600+
const { sock } = await mountSessions()
601+
listed(sock, [info({ id: 's1' })])
602+
act(() =>
603+
sock.emitControl({
604+
type: 'welcome',
605+
daemonId: 'd1',
606+
host: 'mesa.local',
607+
ver: '0.1.0',
608+
relay: { status: 'connected', origin: 'https://relay.example' },
609+
}),
610+
)
611+
await waitFor(() => expect(screen.getByText(/holds\s+no key for the fleet/)).toBeTruthy())
612+
613+
act(() =>
614+
sock.emitControl({ type: 'welcome', daemonId: 'd1', host: 'mesa.local', ver: '0.1.0' }),
615+
)
616+
617+
await waitFor(() => expect(screen.queryByText(/no key for the fleet/)).toBeNull())
618+
})
619+
564620
it('reports a lost local daemon rather than showing an empty screen', async () => {
565621
const { sock } = await mountSessions()
566622
listed(sock, [info({ id: 's1' })])

web/src/routes/sessions.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,9 @@ export function SessionsRoute() {
169169
/**
170170
* What the fleet could not build for this browser: machines it has no
171171
* certificate for, and whether it pinned a fleet key at all. Null until an
172-
* expansion has run, which is every tab before its welcome names a relay.
172+
* expansion has run, which is every tab before its welcome names a relay —
173+
* and null again the moment its machine says it is on none, because there is
174+
* then no fleet for any of it to be a fact about (FleetClient.gaps).
173175
*
174176
* Read off the fleet on each delivery rather than carried in the payload,
175177
* because it changes once per expansion and the payload is delivered several
@@ -868,6 +870,15 @@ function PlacedBulkBar(props: {
868870
* Silent when there is nothing to say, which is now the ordinary case
869871
* everywhere — including on loopback, where enrolment is what closes the last
870872
* two gaps. A band reading "everything is fine" is a band nobody reads.
873+
*
874+
* And silent before any of that on a machine with no relay, which is what a
875+
* fresh install is and the first screen anybody ever opens. Every sentence
876+
* below is addressed to a reader who is on a fleet: there are no others to be
877+
* missing from a list, and nothing anywhere to pair against, so all three would
878+
* be false at once. The fleet is what makes that hold rather than a fourth
879+
* branch here — it hands over no gaps at all while this tab knows no relay
880+
* (fleet/fleet.ts, FleetClient.gaps) — and the invitation to set up remote
881+
* access is the Remote screen's to make, on the screen that can carry it out.
871882
*/
872883
function FleetGapBand({ gaps }: { gaps: FleetGaps }) {
873884
if (!gaps.fleetKey && gaps.pinned === 0) {

0 commit comments

Comments
 (0)